docs: update architecture and config schema; feat: enhance hermes adapter and deploy script
CI / validate (push) Failing after 1s

This commit is contained in:
2026-06-19 22:51:13 -04:00
parent ea262a38bb
commit b60d541d6c
4 changed files with 184 additions and 47 deletions
+1
View File
@@ -8,6 +8,7 @@ agent:
name: "tanko" # Agent name (lowercase, no spaces)
display_name: "Tanko" # Human-readable display name
zulip_bot_name: "tanko-bot" # Zulip bot username (ADR-003 naming)
bot_id: 1234 # Zulip user ID of this bot (required for mentioned_users logic)
owner_email: "jerome@sysloggh.net" # Human owner for private topic ACL
private_topic: "tanko-private" # Private topic name (ADR-001 naming)
+6 -1
View File
@@ -59,7 +59,12 @@ User types: @all-bots status report
└── 6 independent responses appear in the topic
```
## Platform Plugin Contracts
## Plugin Communication Logic
- **Isolation**: Each agent (Tanko, Mumuni, etc.) runs as an independent process on its own Compute Target (CT).
- **State**: Agents are stateless; all "memory" is handled via the RA-H OS Knowledge Graph and the Hermes persistent memory tool.
- **Communication**: Agents communicate via Zulip. There is no direct A2A (Agent-to-Agent) messaging layer. Instead, each agent listens to the `#agent-hub` and responds to specific mentions or `@all-bots` pings.
- **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/`
+113 -6
View File
@@ -1,26 +1,133 @@
# Core adapter implements BasePlatformAdapter for Zulip
# Core adapter implements BasePlatformAdapter for Zulip
# See ADR-005 for @mention detection, ADR-009 for error handling
# Full implementation in issue #5. This skeleton establishes the package.
# Full implementation in issue #5.
import logging
import time
import re
import threading
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class ZulipAdapter:
"""Zulip adapter for Hermes agents. Connects to Zulip, listens for
@mentions in #agent-hub, routes messages via BasePlatformAdapter."""
# Regex to strip Zulip mention artifacts from message bodies (ADR-008)
MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self.connected = False
self._client = None
self._thread = None
self._stop_event = threading.Event()
async def connect(self) -> None:
raise NotImplementedError("Skeleton — implement in issue #5")
"""Establish connection to Zulip and start the event loop."""
if self.connected:
logger.info("Already connected to Zulip.")
return
try:
from zulip import Client
server_url = self.config['zulip']['server_url']
email = self.config['zulip']['email']
api_key = self.config['zulip']['api_key']
self._client = Client(server_url=server_url, email=email, api_key=api_key)
self.connected = True
logger.info(f"Connected to Zulip: {server_url} as {email}")
# Start the event loop in a background thread
self._stop_event.clear()
self._thread = threading.Thread(target=self._event_loop, daemon=True)
self._thread.start()
logger.info("Zulip event loop started.")
except Exception as e:
logger.error(f"Failed to connect to Zulip: {e}")
self.connected = False
raise
async def disconnect(self) -> None:
raise NotImplementedError("Skeleton — implement in issue #5")
"""Disconnect from Zulip and stop the event loop."""
if not self.connected:
return
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
self.connected = False
logger.info("Disconnected from Zulip.")
async def send_message(self, topic: str, content: str) -> Dict[str, Any]:
raise NotImplementedError("Skeleton — implement in issue #5")
"""Send a message to Zulip with retry logic (ADR-009)."""
max_retries = self.config.get('error_handling', {}).get('retry_count', 3)
retry_delay = self.config.get('error_handling', {}).get('retry_delay_seconds', 5)
for attempt in range(max_retries):
try:
result = self._client.send_message({
'type': 'stream',
'to': self.config['zulip']['stream'],
'subject': topic,
'content': content,
})
logger.info(f"Message sent to {self.config['zulip']['stream']} > {topic} (attempt {attempt + 1})")
return result
except Exception as e:
logger.warning(f"Failed to send message (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
time.sleep(retry_delay)
else:
raise
async def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
raise NotImplementedError("Skeleton — implement in issue #5")
"""Process incoming Zulip events and route via BasePlatformAdapter."""
if event.get('type') != 'message':
return None
msg = event.get('message', {})
mentioned_users = msg.get('mentioned_users', False)
# Only process messages that mention this bot (ADR-005)
if not mentioned_users:
return None
# Strip Zulip formatting artifacts
body = msg.get('content', '')
clean_body = self.MENTION_CLEANER.sub('', body)
clean_body = clean_body.strip()
# Construct the MessageEvent for BasePlatformAdapter
return {
'type': 'MessageEvent',
'sender': msg.get('sender_full_name', 'Unknown'),
'body': clean_body,
'topic': msg.get('subject', 'Unknown Topic'),
'stream': msg.get('stream', 'Unknown Stream'),
'timestamp': msg.get('timestamp', 0),
}
def _event_loop(self) -> None:
"""Background thread to listen for Zulip events."""
try:
self._client.call_on_each_message(
lambda event: self._process_event(event),
)
except Exception as e:
logger.error(f"Event loop crashed: {e}")
self.connected = False
def _process_event(self, event: Dict[str, Any]) -> None:
"""Bridge the synchronous event to the async on_event handler."""
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.create_task(self.on_event(event))
else:
asyncio.run(self.on_event(event))
except Exception as e:
logger.error(f"Error processing event: {e}")
+40 -16
View File
@@ -1,25 +1,28 @@
#!/usr/bin/env bash
# deploy.sh GitOps deployment for zulip-platform-plugins
# Usage: ./deploy.sh <tag|branch> [--ct=<agent>]
# 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
set -euo pipefail
DEPLOY_LOG="deploy.log"
TAG=""
SINGLE_CT=""
DRY_RUN=false
# Parse args
for arg in "$@"; do
case "$arg" in
--ct=*) SINGLE_CT="${arg#--ct=}" ;;
--dry-run) DRY_RUN=true ;;
*) TAG="$arg" ;;
esac
done
if [[ -z "$TAG" ]]; then
echo "Usage: $0 <tag|branch> [--ct=<agent>]"
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--dry-run]"
exit 1
fi
@@ -38,11 +41,16 @@ declare -A PLUGIN_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=(
["tanko"]="zulip-plugin"
["mumuni"]="zulip-plugin"
@@ -56,7 +64,9 @@ GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
HEALTH_PORT=9200
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
local prefix=""
[[ "$DRY_RUN" == "true" ]] && prefix="[DRY-RUN] "
echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
}
deploy_agent() {
@@ -69,12 +79,17 @@ deploy_agent() {
log "Path: $plugin_path"
# 1. Git pull & checkout tag
cd "$plugin_path" || { log "ERROR: $agent — path $plugin_path not found"; return 1; }
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
@@ -83,16 +98,17 @@ deploy_agent() {
pip install -r requirements.txt --quiet
;;
abiba)
# pi extensions are TypeScript — no pip install needed
# If npm deps needed: npm ci
log "$agent: pi extension — skipping pip install"
log "$agent: pi extension skipping pip install"
;;
esac
else
log "Skipping dependency installation (Dry Run)"
fi
# 3. Restart service
if [[ "$DRY_RUN" != "true" ]]; then
case "$agent" in
abiba)
# pi reload via command, not systemctl
log "$agent: triggering /reload"
;;
*)
@@ -100,19 +116,27 @@ deploy_agent() {
log "$agent: restarted $service"
;;
esac
else
log "Skipping service restart (Dry Run)"
fi
# 4. Health check
sleep 3
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"
else
log "WARN: $agent health check failed check logs"
log "ERROR: $agent health check failed check logs"
return 1
fi
else
log "Skipping health check (Dry Run)"
fi
}
# --- Main ---
log "Deploy started target: $TAG"
# --- Main ---\
log "Deploy started target: $TAG (Dry Run: $DRY_RUN)"
if [[ -n "$SINGLE_CT" ]]; then
deploy_agent "$SINGLE_CT"