From b60d541d6cb2b2601c000c2a707022520898dae0 Mon Sep 17 00:00:00 2001 From: Jerome Tabiri Date: Fri, 19 Jun 2026 22:51:13 -0400 Subject: [PATCH] docs: update architecture and config schema; feat: enhance hermes adapter and deploy script --- config.yaml.example | 1 + docs/ARCHITECTURE.md | 7 +- .../src/hermes_zulip/adapter.py | 119 +++++++++++++++++- scripts/deploy.sh | 104 +++++++++------ 4 files changed, 184 insertions(+), 47 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index 9dc15c4..d548574 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31739b5..3c54599 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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/` diff --git a/hermes-zulip-plugin/src/hermes_zulip/adapter.py b/hermes-zulip-plugin/src/hermes_zulip/adapter.py index e9ee789..fea2470 100644 --- a/hermes-zulip-plugin/src/hermes_zulip/adapter.py +++ b/hermes-zulip-plugin/src/hermes_zulip/adapter.py @@ -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}") diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 19f8789..7cbd533 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,25 +1,28 @@ #!/usr/bin/env bash -# deploy.sh — GitOps deployment for zulip-platform-plugins -# Usage: ./deploy.sh [--ct=] +# deploy.sh GitOps deployment for zulip-platform-plugins +# Usage: ./deploy.sh [--ct=] [--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 [--ct=]" + echo "Usage: $0 [--ct=] [--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"