docs: update architecture and config schema; feat: enhance hermes adapter and deploy script
CI / validate (push) Failing after 1s
CI / validate (push) Failing after 1s
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+64
-40
@@ -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,50 +79,64 @@ deploy_agent() {
|
||||
log "Path: $plugin_path"
|
||||
|
||||
# 1. Git pull & checkout tag
|
||||
cd "$plugin_path" || { log "ERROR: $agent — path $plugin_path not found"; return 1; }
|
||||
git fetch --tags origin
|
||||
git 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)
|
||||
case "$agent" in
|
||||
tanko|mumuni|koonimo|koby)
|
||||
pip install -r requirements.txt --quiet
|
||||
;;
|
||||
kagentz)
|
||||
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"
|
||||
;;
|
||||
esac
|
||||
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
|
||||
|
||||
# 3. Restart service
|
||||
case "$agent" in
|
||||
abiba)
|
||||
# pi reload via command, not systemctl
|
||||
log "$agent: triggering /reload"
|
||||
;;
|
||||
*)
|
||||
systemctl restart "$service"
|
||||
log "$agent: restarted $service"
|
||||
;;
|
||||
esac
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
case "$agent" in
|
||||
abiba)
|
||||
log "$agent: triggering /reload"
|
||||
;;
|
||||
*)
|
||||
systemctl restart "$service"
|
||||
log "$agent: restarted $service"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log "Skipping service restart (Dry Run)"
|
||||
fi
|
||||
|
||||
# 4. Health check
|
||||
sleep 3
|
||||
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||
log "OK: $agent health check passed"
|
||||
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 "ERROR: $agent health check failed check logs"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log "WARN: $agent health check failed — check logs"
|
||||
return 1
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user