Compare commits
15
Commits
2ee8a4d818
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6536ea57 | ||
|
|
4db655d4f9 | ||
|
|
1cf6c2f806 | ||
|
|
c6beb650c4 | ||
|
|
1b0f1c627a | ||
|
|
3690a7f568 | ||
|
|
870f64d8a9 | ||
|
|
48bc66b42f | ||
|
|
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.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Deploy Agent Zero Zulip Adapter
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Agent Zero running in Docker on the target CT
|
||||||
|
- A Zulip bot created for the agent (kagentz-bot, scottdenya-bot, etc.)
|
||||||
|
- Python 3.11+ with httpx
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Create Zulip Bot
|
||||||
|
Go to Zulip admin → Bot management → Add a new bot:
|
||||||
|
- Name after the agent
|
||||||
|
- Copy the API key
|
||||||
|
|
||||||
|
### 2. Install Adapter
|
||||||
|
```bash
|
||||||
|
# On the target CT
|
||||||
|
pip install httpx
|
||||||
|
|
||||||
|
# Copy adapter files
|
||||||
|
scp -r agent-zero-zulip/ root@<ct-ip>:/opt/agent-zero-zulip/
|
||||||
|
|
||||||
|
# Create config
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
# Edit config.yaml with Zulip credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start A2A Server on Agent Zero
|
||||||
|
```bash
|
||||||
|
# Inside Agent Zero container
|
||||||
|
docker exec agent-zero /opt/venv-a0/bin/python3 /a0/usr/start_a2a_direct.py &
|
||||||
|
|
||||||
|
# Verify A2A is running
|
||||||
|
curl http://localhost:8001/.well-known/agent.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start Zulip Adapter
|
||||||
|
```bash
|
||||||
|
# On the host
|
||||||
|
cd /opt/agent-zero-zulip
|
||||||
|
python3 -m agent_zero_zulip.adapter &
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
tail -f adapter.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test
|
||||||
|
Send a DM to kagentz-bot in Zulip. Expect "Processing..." → response.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Agent Zero Zulip Adapter
|
||||||
|
|
||||||
|
Direct Zulip integration for Agent Zero agents (kagentz, scottdenya, etc.).
|
||||||
|
Each agent gets its own Zulip bot. Uses A2A protocol internally to
|
||||||
|
communicate with Agent Zero's native A2A server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ kagentz (CT 105) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Agent Zero │◄──►│ Zulip A2A │ │
|
||||||
|
│ │ (Docker) │A2A │ Adapter │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ port 8001 │ │ polls Zulip │ │
|
||||||
|
│ └──────────────┘ └──────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────┼───────────┘
|
||||||
|
│ Zulip API
|
||||||
|
▼
|
||||||
|
chat.sysloggh.net
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `adapter.py` — Zulip event queue poller + A2A client
|
||||||
|
- `agent_card.py` — Agent Card for A2A discovery
|
||||||
|
- `Dockerfile` or `requirements.txt` for dependencies
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Agent Zero Zulip Adapter Configuration
|
||||||
|
# Copy to config.yaml and fill in credentials
|
||||||
|
|
||||||
|
zulip:
|
||||||
|
site: https://chat.sysloggh.net
|
||||||
|
email: kagentz-bot@chat.sysloggh.net
|
||||||
|
api_key: "<your-api-key>"
|
||||||
|
stream: agent-hub
|
||||||
|
|
||||||
|
agent:
|
||||||
|
name: kagentz
|
||||||
|
a2a_url: http://localhost:8001/a2a
|
||||||
|
a2a_token: 8zNgdOEXzYxjQvTl
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
httpx>=0.27.0
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""A2A server for kagentz — calls LiteLLM directly for responses."""
|
||||||
|
import os, json, uuid, asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
task_results = {}
|
||||||
|
LITELLM_URL = os.getenv("LITELLM_URL", "https://litellm.sysloggh.net/v1")
|
||||||
|
LITELLM_KEY = os.getenv("LITELLM_KEY", "sk-akDPhdO8qlhYNtVet6KZog")
|
||||||
|
LITELLM_MODEL = os.getenv("LITELLM_MODEL", "syslog-auto")
|
||||||
|
NOW = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
|
||||||
|
@app.get("/.well-known/agent.json")
|
||||||
|
async def agent_card():
|
||||||
|
return {
|
||||||
|
"name": "kagentz",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Agent Zero on CT 105 via Zulip + LiteLLM",
|
||||||
|
"capabilities": {"a2a": {"version": "1.0"}},
|
||||||
|
"skills": [{"id": "chat", "name": "Chat"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/a2a")
|
||||||
|
async def a2a_endpoint(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
method = body.get("method", "")
|
||||||
|
|
||||||
|
if method == "tasks/send":
|
||||||
|
params = body.get("params", {})
|
||||||
|
msg = params.get("message", {})
|
||||||
|
parts = msg.get("parts", [])
|
||||||
|
text = " ".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
||||||
|
task_id = f"az-{uuid.uuid4().hex[:12]}"
|
||||||
|
asyncio.create_task(call_litellm(task_id, text))
|
||||||
|
return {"jsonrpc": "2.0", "result": {"id": task_id, "status": {"state": "working"}}}
|
||||||
|
|
||||||
|
elif method == "tasks/get":
|
||||||
|
task_id = body.get("params", {}).get("id", "")
|
||||||
|
status, result = task_results.get(task_id, ("pending", None))
|
||||||
|
resp = {"jsonrpc": "2.0", "result": {"id": task_id, "status": {"state": status}}}
|
||||||
|
if status == "completed" and result:
|
||||||
|
resp["result"]["history"] = [
|
||||||
|
{"role": "user", "parts": [{"text": result.get("input", "")}]},
|
||||||
|
{"role": "assistant", "parts": [{"text": result.get("output", "")}]},
|
||||||
|
]
|
||||||
|
elif status == "failed":
|
||||||
|
resp["result"]["status"]["message"] = {"text": str(result)}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}}
|
||||||
|
|
||||||
|
async def call_litellm(task_id, message):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_KEY}", "Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"model": LITELLM_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": f"You are kagentz, an Agent Zero instance. Current date: {NOW}. Keep responses concise and accurate."},
|
||||||
|
{"role": "user", "content": message},
|
||||||
|
],
|
||||||
|
"max_tokens": 2000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.is_success:
|
||||||
|
output = resp.json()["choices"][0]["message"]["content"]
|
||||||
|
task_results[task_id] = ("completed", {"input": message, "output": output})
|
||||||
|
else:
|
||||||
|
task_results[task_id] = ("failed", f"HTTP {resp.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
task_results[task_id] = ("failed", str(e))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"kagentz A2A server on port 8001 (model={LITELLM_MODEL}, date={NOW})")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="info")
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""
|
||||||
|
Agent Zero Zulip Adapter — Direct Zulip integration for Agent Zero agents.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
Zulip event queue → poll loop → A2A send to Agent Zero →
|
||||||
|
wait for A2A response → post back to Zulip
|
||||||
|
|
||||||
|
Runs alongside Agent Zero on the same host. Communicates with Agent Zero
|
||||||
|
via local A2A protocol (port 8001). No intermediate bridge needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
httpx = None
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-zero-zulip")
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
DEFAULT_A2A_URL = "http://localhost:8001/a2a"
|
||||||
|
DEFAULT_A2A_TOKEN = "8zNgdOEXzYxjQvTl"
|
||||||
|
DEFAULT_POLL_INTERVAL = 3.0
|
||||||
|
MAX_ZULIP_MSG = 10000
|
||||||
|
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||||
|
HEARTBEAT_INTERVAL = 300 # 5 min
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZeroZulipAdapter:
|
||||||
|
"""Zulip adapter for Agent Zero agents.
|
||||||
|
|
||||||
|
Connects to Zulip via event queue, forwards DMs and @mentions
|
||||||
|
to Agent Zero via local A2A protocol, and posts responses back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Zulip config from env vars
|
||||||
|
self._site = (os.getenv("ZULIP_SITE") or "").rstrip("/")
|
||||||
|
self._email = os.getenv("ZULIP_EMAIL") or ""
|
||||||
|
self._api_key = os.getenv("ZULIP_API_KEY") or ""
|
||||||
|
self._agent_name = os.getenv("ZULIP_AGENT_NAME") or "kagentz"
|
||||||
|
self._stream = os.getenv("ZULIP_STREAM") or "agent-hub"
|
||||||
|
|
||||||
|
# A2A config
|
||||||
|
self._a2a_url = os.getenv("A2A_URL") or DEFAULT_A2A_URL
|
||||||
|
self._a2a_token = os.getenv("A2A_TOKEN") or DEFAULT_A2A_TOKEN
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._auth_header = self._build_auth()
|
||||||
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._queue_id: Optional[str] = None
|
||||||
|
self._last_event_id: int = -1
|
||||||
|
self._bot_user_id: Optional[int] = None
|
||||||
|
self._bot_email: str = self._email
|
||||||
|
self._running = False
|
||||||
|
self._poll_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
self._messages_processed = 0
|
||||||
|
self._poll_count = 0
|
||||||
|
self._reconnects = 0
|
||||||
|
self._last_heartbeat = 0.0
|
||||||
|
self._last_event_time = time.time()
|
||||||
|
self._a2a_sessions: dict = {} # chat_id -> A2A context_id
|
||||||
|
|
||||||
|
def _build_auth(self) -> str:
|
||||||
|
import base64
|
||||||
|
token = base64.b64encode(f"{self._email}:{self._api_key}".encode()).decode()
|
||||||
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the adapter — connect to Zulip and begin polling."""
|
||||||
|
if not all([self._site, self._email, self._api_key]):
|
||||||
|
logger.error("Missing Zulip credentials. Set ZULIP_SITE, ZULIP_EMAIL, ZULIP_API_KEY")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not httpx:
|
||||||
|
logger.error("httpx not installed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
# Connect to Zulip
|
||||||
|
if not await self._connect():
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Adapter started for {self._agent_name} ({self._email})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the adapter."""
|
||||||
|
self._running = False
|
||||||
|
if self._poll_task:
|
||||||
|
self._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self._http_client:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
|
||||||
|
async def _connect(self) -> bool:
|
||||||
|
"""Register Zulip event queue."""
|
||||||
|
try:
|
||||||
|
resp = await self._api_call("POST", "/api/v1/register", data={
|
||||||
|
"event_types": '["message"]',
|
||||||
|
"apply_markdown": "true",
|
||||||
|
})
|
||||||
|
if not resp:
|
||||||
|
logger.error("Queue registration failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._queue_id = resp.get("queue_id")
|
||||||
|
self._last_event_id = resp.get("last_event_id", -1)
|
||||||
|
self._bot_user_id = resp.get("user_id")
|
||||||
|
|
||||||
|
# Resolve bot user_id if not provided by register
|
||||||
|
if self._bot_user_id is None:
|
||||||
|
me = await self._api_call("GET", "/api/v1/users/me")
|
||||||
|
if me:
|
||||||
|
self._bot_user_id = me.get("user_id")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Connected to Zulip as {self._email} "
|
||||||
|
f"(queue={self._queue_id}, bot_id={self._bot_user_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start poll loop
|
||||||
|
self._poll_task = asyncio.create_task(self._poll_forever())
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _poll_forever(self):
|
||||||
|
"""Main poll loop."""
|
||||||
|
backoff = 0
|
||||||
|
while self._running:
|
||||||
|
if not self._queue_id:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = await self._fetch_events()
|
||||||
|
self._poll_count += 1
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
try:
|
||||||
|
await self._process_event(event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Event processing error: {e}")
|
||||||
|
|
||||||
|
backoff = 0
|
||||||
|
|
||||||
|
# Heartbeat
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_heartbeat > HEARTBEAT_INTERVAL:
|
||||||
|
self._last_heartbeat = now
|
||||||
|
silence = now - self._last_event_time
|
||||||
|
logger.info(
|
||||||
|
f"Heartbeat — polls={self._poll_count} "
|
||||||
|
f"processed={self._messages_processed} "
|
||||||
|
f"silence={silence:.0f}s reconnects={self._reconnects}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e)
|
||||||
|
if "BAD_EVENT_QUEUE_ID" in err:
|
||||||
|
logger.info("Queue expired, reconnecting...")
|
||||||
|
await self._reconnect()
|
||||||
|
continue
|
||||||
|
delay = RECONNECT_BACKOFF[min(backoff, len(RECONNECT_BACKOFF) - 1)]
|
||||||
|
backoff += 1
|
||||||
|
logger.warning(f"Poll error: {e}")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
await asyncio.sleep(DEFAULT_POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def _fetch_events(self) -> list:
|
||||||
|
"""Fetch events from Zulip queue."""
|
||||||
|
resp = await self._api_call("GET", "/api/v1/events", params={
|
||||||
|
"queue_id": self._queue_id,
|
||||||
|
"last_event_id": str(self._last_event_id),
|
||||||
|
"dont_block": "true",
|
||||||
|
})
|
||||||
|
if resp is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = resp.get("events", [])
|
||||||
|
for event in events:
|
||||||
|
eid = event.get("id", 0)
|
||||||
|
if eid > self._last_event_id:
|
||||||
|
self._last_event_id = eid
|
||||||
|
|
||||||
|
return [e for e in events if e.get("type") == "message"]
|
||||||
|
|
||||||
|
async def _reconnect(self):
|
||||||
|
"""Re-register event queue."""
|
||||||
|
self._queue_id = None
|
||||||
|
try:
|
||||||
|
resp = await self._api_call("POST", "/api/v1/register", data={
|
||||||
|
"event_types": '["message"]',
|
||||||
|
"apply_markdown": "true",
|
||||||
|
})
|
||||||
|
if resp:
|
||||||
|
self._queue_id = resp.get("queue_id")
|
||||||
|
self._last_event_id = resp.get("last_event_id", -1)
|
||||||
|
self._reconnects += 1
|
||||||
|
logger.info(f"Reconnected, queue={self._queue_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reconnect failed: {e}")
|
||||||
|
|
||||||
|
async def _process_event(self, event: dict):
|
||||||
|
"""Process a Zulip message event."""
|
||||||
|
msg = event.get("message", {})
|
||||||
|
msg_type = msg.get("type", "")
|
||||||
|
sender_email = msg.get("sender_email", "")
|
||||||
|
sender_name = msg.get("sender_full_name", "Unknown")
|
||||||
|
sender_id = msg.get("sender_id")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
# Ignore own messages
|
||||||
|
if sender_email == self._bot_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine targeting
|
||||||
|
mentioned_users = msg.get("mentioned_users", []) or []
|
||||||
|
mentioned_ids = [u.get("user_id") for u in mentioned_users if isinstance(u, dict)]
|
||||||
|
is_dm = msg_type == "private"
|
||||||
|
is_mention = self._bot_user_id and self._bot_user_id in mentioned_ids
|
||||||
|
|
||||||
|
if is_dm:
|
||||||
|
self._messages_processed += 1
|
||||||
|
self._last_event_time = time.time()
|
||||||
|
logger.info(f"DM from {sender_name}: {content[:80]}...")
|
||||||
|
|
||||||
|
# Send typing indicator
|
||||||
|
await self._typing(sender_id, "start")
|
||||||
|
|
||||||
|
# Send placeholder
|
||||||
|
placeholder_id = await self._send_msg("private", str(sender_id),
|
||||||
|
":robot: _Processing your message..._")
|
||||||
|
|
||||||
|
# Forward to Agent Zero via A2A
|
||||||
|
response = await self._a2a_chat(sender_name, content)
|
||||||
|
|
||||||
|
# Stop typing
|
||||||
|
await self._typing(sender_id, "stop")
|
||||||
|
|
||||||
|
# Post response
|
||||||
|
if response and placeholder_id:
|
||||||
|
await self._edit_msg(placeholder_id, response[:MAX_ZULIP_MSG])
|
||||||
|
logger.info(f"Responded to {sender_name} ({len(response)} chars)")
|
||||||
|
elif response:
|
||||||
|
await self._send_msg("private", str(sender_id), response[:MAX_ZULIP_MSG])
|
||||||
|
|
||||||
|
async def _a2a_chat(self, sender_name: str, message: str) -> Optional[str]:
|
||||||
|
"""Send message to Agent Zero via local A2A protocol."""
|
||||||
|
try:
|
||||||
|
a2a_payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tasks/send",
|
||||||
|
"params": {
|
||||||
|
"id": f"zulip-{int(time.time())}",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{"text": f"[Zulip DM from {sender_name}]: {message}"}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._a2a_token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
# Send the message to Agent Zero
|
||||||
|
resp = await client.post(
|
||||||
|
self._a2a_url,
|
||||||
|
json=a2a_payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"A2A send failed: HTTP {resp.status_code}")
|
||||||
|
return f":warning: Failed to reach agent. Error: HTTP {resp.status_code}"
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
task_id = result.get("result", {}).get("id")
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
logger.error("A2A: no task_id returned")
|
||||||
|
return ":warning: Agent did not create a task."
|
||||||
|
|
||||||
|
# Poll for completion
|
||||||
|
for _ in range(60): # max 60s wait
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
status_resp = await client.post(
|
||||||
|
self._a2a_url,
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tasks/get",
|
||||||
|
"params": {"id": task_id},
|
||||||
|
"id": 2
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status_data = status_resp.json()
|
||||||
|
result_data = status_data.get("result", {})
|
||||||
|
state = result_data.get("status", {}).get("state", "")
|
||||||
|
|
||||||
|
if state == "completed":
|
||||||
|
# Extract response text
|
||||||
|
return self._extract_a2a_response(result_data)
|
||||||
|
elif state in ("failed", "error"):
|
||||||
|
error_msg = result_data.get("status", {}).get("message", {}).get("text", "Unknown error")
|
||||||
|
return f":warning: Agent processing failed: {error_msg}"
|
||||||
|
|
||||||
|
return ":hourglass: Agent did not complete in time."
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return ":hourglass: Agent request timed out."
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"A2A error: {e}")
|
||||||
|
return f":warning: Communication error: {type(e).__name__}"
|
||||||
|
|
||||||
|
def _extract_a2a_response(self, result: dict) -> str:
|
||||||
|
"""Extract assistant text from A2A response."""
|
||||||
|
history = result.get("history", [])
|
||||||
|
for msg in reversed(history):
|
||||||
|
if isinstance(msg, dict) and msg.get("role") == "assistant":
|
||||||
|
parts = msg.get("parts", [])
|
||||||
|
texts = [p.get("text", "") for p in parts if isinstance(p, dict)]
|
||||||
|
text = "\n".join(t for t in texts if t)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
artifacts = result.get("artifacts", [])
|
||||||
|
for artifact in reversed(artifacts):
|
||||||
|
if isinstance(artifact, dict):
|
||||||
|
text = artifact.get("text") or artifact.get("content") or ""
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return "(no response)"
|
||||||
|
|
||||||
|
# ── Zulip API ──
|
||||||
|
|
||||||
|
async def _api_call(self, method: str, path: str,
|
||||||
|
data: dict = None, params: dict = None) -> Optional[dict]:
|
||||||
|
"""Make Zulip API call."""
|
||||||
|
if not self._http_client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self._site}{path}"
|
||||||
|
headers = {"Authorization": self._auth_header}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
resp = await self._http_client.get(url, params=params, headers=headers)
|
||||||
|
elif method == "POST":
|
||||||
|
resp = await self._http_client.post(url, data=data, headers=headers)
|
||||||
|
elif method == "PATCH":
|
||||||
|
resp = await self._http_client.patch(url, data=data, headers=headers)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.debug(f"API {method} {path}: {resp.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"API error {method} {path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_msg(self, msg_type: str, to: str, content: str) -> Optional[int]:
|
||||||
|
"""Send a message to Zulip."""
|
||||||
|
payload = {"type": msg_type, "content": content}
|
||||||
|
if msg_type == "private":
|
||||||
|
payload["to"] = json.dumps([int(to)])
|
||||||
|
else:
|
||||||
|
payload["to"] = to
|
||||||
|
payload["subject"] = "general"
|
||||||
|
|
||||||
|
resp = await self._api_call("POST", "/api/v1/messages", data=payload)
|
||||||
|
if resp and resp.get("id"):
|
||||||
|
return resp["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _edit_msg(self, message_id: int, content: str):
|
||||||
|
"""Edit a Zulip message."""
|
||||||
|
await self._api_call("PATCH", f"/api/v1/messages/{message_id}",
|
||||||
|
data={"content": content})
|
||||||
|
|
||||||
|
async def _typing(self, user_id: int, op: str):
|
||||||
|
"""Send typing indicator."""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
to_data = json.dumps([user_id])
|
||||||
|
await self._api_call("POST", "/api/v1/typing",
|
||||||
|
data={"to": to_data, "op": op})
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Synchronous entry point."""
|
||||||
|
asyncio.run(self._run_async())
|
||||||
|
|
||||||
|
async def _run_async(self):
|
||||||
|
try:
|
||||||
|
if await self.start():
|
||||||
|
# Keep running until stopped
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entry point ──
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = AgentZeroZulipAdapter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutdown requested")
|
||||||
|
|
||||||
|
# Handle signals
|
||||||
|
import signal
|
||||||
|
shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
def _handle_signal():
|
||||||
|
shutdown.set()
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(signal.SIGTERM, _handle_signal)
|
||||||
|
loop.add_signal_handler(signal.SIGINT, _handle_signal)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
if await adapter.start():
|
||||||
|
await shutdown.wait()
|
||||||
|
await adapter.stop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(_run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+32
-7
@@ -1,8 +1,20 @@
|
|||||||
# Zulip Multi-Platform Agent Communication — Architecture
|
# Zulip Multi-Platform Agent Communication — Architecture (v2)
|
||||||
|
|
||||||
## 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
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
@@ -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.
|
- **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
|
### Hermes (Python) — BasePlatformAdapter (CURRENT)
|
||||||
- Path: `hermes-zulip-plugin/src/hermes_zulip/`
|
- Path: `plugins/platforms/zulip/`
|
||||||
|
- Deploy: `~/.hermes/plugins/platforms/zulip/`
|
||||||
- Implements: `BasePlatformAdapter` (Hermes Gateway)
|
- Implements: `BasePlatformAdapter` (Hermes Gateway)
|
||||||
- Config: `config.yaml` per-agent
|
- Config: Hermes `config.yaml` under `platforms.zulip.extra` or env vars
|
||||||
- Entry point: `plugin.yaml` (Hermes manifest)
|
- 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/`
|
- 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
|
### 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/`
|
- 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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+181
-79
@@ -1,32 +1,56 @@
|
|||||||
#!/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]
|
#
|
||||||
# ./deploy.sh v1.0.0 # Deploy to all 6 CTs
|
# Supports two deployment modes:
|
||||||
# ./deploy.sh main # Deploy latest (staging only!)
|
# LEGACY: /opt/hermes-zulip-plugin/ + systemctl restart zulip-plugin
|
||||||
# ./deploy.sh --ct=tanko v1.0.0 # Deploy to single CT
|
# NATIVE: ~/.hermes/plugins/platforms/zulip/ + hermes gateway restart
|
||||||
# ./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>] [--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
|
exit 1
|
||||||
fi
|
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=(
|
declare -A AGENTS=(
|
||||||
["tanko"]="amdpve CT 112"
|
["tanko"]="amdpve CT 112"
|
||||||
["mumuni"]="minipve CT 114"
|
["mumuni"]="minipve CT 114"
|
||||||
@@ -36,32 +60,31 @@ declare -A AGENTS=(
|
|||||||
["abiba"]="amdpve CT 100"
|
["abiba"]="amdpve CT 100"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Platform paths per agent type
|
# Native Hermes plugin path (~/.hermes/plugins/platforms/zulip/)
|
||||||
declare -A PLUGIN_PATHS=(
|
NATIVE_PLUGIN_SRC="plugins/platforms/zulip"
|
||||||
|
|
||||||
|
# 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Correcting the backslash artifacts from the original file
|
# Service names for legacy mode
|
||||||
PLUGIN_PATHS["koby"]="/opt/hermes-zulip-plugin"
|
declare -A LEGACY_SERVICES=(
|
||||||
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" # pi reload, not systemctl
|
["abiba"]="pi"
|
||||||
)
|
)
|
||||||
|
|
||||||
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
|
||||||
HEALTH_PORT=9200
|
HEALTH_PORT=9200
|
||||||
|
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
local prefix=""
|
local prefix=""
|
||||||
@@ -69,90 +92,169 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy_agent() {
|
# ── Deployment Functions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
deploy_native() {
|
||||||
local agent="$1"
|
local agent="$1"
|
||||||
local ct_info="${AGENTS[$agent]}"
|
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip"
|
||||||
local plugin_path="${PLUGIN_PATHS[$agent]}"
|
|
||||||
local service="${SERVICE_NAMES[$agent]}"
|
|
||||||
|
|
||||||
log "=== Deploying $agent ($ct_info) @ $TAG ==="
|
log "📦 Deploying $agent: native Hermes plugin -> $plugin_dir"
|
||||||
log "Path: $plugin_path"
|
|
||||||
|
|
||||||
# 1. Git pull & checkout tag
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
log " Would copy $NATIVE_PLUGIN_SRC/{adapter.py,__init__.py,plugin.yaml} -> $plugin_dir/"
|
||||||
cd "$plugin_path" || { log "ERROR: $agent path $plugin_path not found"; return 1; }
|
log " Would run: hermes gateway restart"
|
||||||
git fetch --tags origin
|
return 0
|
||||||
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)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Restart service
|
# Create plugin directory
|
||||||
if [[ "$DRY_RUN" != "true" ]]; then
|
mkdir -p "$plugin_dir"
|
||||||
case "$agent" in
|
|
||||||
abiba)
|
# Copy plugin files
|
||||||
log "$agent: triggering /reload"
|
cp "$NATIVE_PLUGIN_SRC/adapter.py" "$plugin_dir/"
|
||||||
;;
|
cp "$NATIVE_PLUGIN_SRC/__init__.py" "$plugin_dir/"
|
||||||
*)
|
cp "$NATIVE_PLUGIN_SRC/plugin.yaml" "$plugin_dir/"
|
||||||
systemctl restart "$service"
|
|
||||||
log "$agent: restarted $service"
|
log " Copied plugin files to $plugin_dir/"
|
||||||
;;
|
ls -la "$plugin_dir/"
|
||||||
esac
|
|
||||||
|
# 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
|
else
|
||||||
log "Skipping service restart (Dry Run)"
|
log " ⚠️ 'hermes' command not found — manual restart needed"
|
||||||
|
log " Run: hermes gateway restart"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Health check
|
# Health check
|
||||||
log " Waiting for service to stabilize..."
|
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 "OK: $agent health check passed"
|
log " ✅ Health check passed (port $HEALTH_PORT)"
|
||||||
else
|
else
|
||||||
log "ERROR: $agent health check failed check logs"
|
log " ⚠️ Health check on port $HEALTH_PORT not responding"
|
||||||
return 1
|
log " Check Gateway logs for plugin load errors"
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Skipping health check (Dry Run)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
log "✅ $agent: native deployment complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Main ---\
|
deploy_legacy() {
|
||||||
log "Deploy started target: $TAG (Dry Run: $DRY_RUN)"
|
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
|
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
|
else
|
||||||
FAILED=""
|
FAILED=""
|
||||||
for agent in tanko mumuni koonimo koby kagentz abiba; do
|
for agent in "${!AGENTS[@]}"; do
|
||||||
if ! deploy_agent "$agent"; then
|
echo ""
|
||||||
|
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 6 agents deployed successfully."
|
log "✅ All agents deployed successfully."
|
||||||
|
log " Next: monitor #agent-hub for agent responses"
|
||||||
fi
|
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