A2A server v2: - Persistent conversation history per sender (20 msg limit) - Homelab system prompt as default project context - tasks/session/new and tasks/session/clear endpoints - Session memory survives across multiple Zulip DMs Adapter: - Passes deterministic session_id per sender_name - Conversations continue naturally without re-explaining context
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""
|
|
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. Raises on BAD_EVENT_QUEUE_ID."""
|
|
resp, status = 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",
|
|
}, return_status=True)
|
|
|
|
# Detect queue expiry
|
|
if status == 400:
|
|
raise RuntimeError("BAD_EVENT_QUEUE_ID: queue expired")
|
|
|
|
if not resp:
|
|
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 with session memory."""
|
|
try:
|
|
# Use sender_name as stable session key so conversation persists
|
|
session_id = f"zulip-{sender_name.lower().replace(' ', '-')}"
|
|
|
|
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}"}]
|
|
},
|
|
"metadata": {
|
|
"session_id": session_id
|
|
}
|
|
},
|
|
"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,
|
|
return_status: bool = False) -> Any:
|
|
"""Make Zulip API call.
|
|
|
|
Returns: dict response body, or if return_status=True returns (dict, int)
|
|
"""
|
|
if not self._http_client:
|
|
return (None, 0) if return_status else 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, 0) if return_status else None
|
|
|
|
if resp.status_code >= 400:
|
|
logger.debug(f"API {method} {path}: {resp.status_code}")
|
|
return (None, resp.status_code) if return_status else None
|
|
|
|
body = resp.json()
|
|
return (body, resp.status_code) if return_status else body
|
|
except Exception as e:
|
|
logger.debug(f"API error {method} {path}: {e}")
|
|
return (None, 0) if return_status else 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()
|