Compare commits

..
Author SHA1 Message Date
Abiba ca95d47dc8 feat: add edit_message + streaming support to Zulip adapter
Implements edit_message() using Zulip's PATCH /api/v1/messages/{id} API.
Enables the Hermes GatewayStreamConsumer to progressively update Zulip
messages during agent generation, giving users real-time visibility into
agent thinking via progressive edits.

Adds _api_patch() helper for PATCH HTTP method.
2026-07-06 00:01:00 +00:00
16 changed files with 49 additions and 2420 deletions
+28 -29
View File
@@ -14,43 +14,42 @@ jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - run: python3 --version
- name: Ensure python3 + PyYAML (act container is alpine-based, no python preinstalled)
run: |
. /etc/os-release
echo "job OS: $ID"
if command -v python3 >/dev/null 2>&1; then
python3 --version
elif command -v apk >/dev/null 2>&1; then
echo "installing via apk"
apk add --no-cache python3 py3-yaml
python3 --version
python3 -c "import yaml" || { echo "yaml import failed after py3-yaml; trying pip"; python3 -m pip install --break-system-packages pyyaml; }
elif command -v apt-get >/dev/null 2>&1; then
echo "installing via apt"
apt-get update -qq
apt-get install -y -qq python3 python3-yaml
python3 --version
else
echo "no python3 and no apk/apt in job image — cannot run CI checks"
exit 1
fi
- run: node --version - run: node --version
- run: echo "Runner works!"
- run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax check - name: Python syntax check
run: | run: |
python3 -m py_compile plugins/platforms/zulip/adapter.py && echo "✅ adapter.py OK" python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null && echo "✅ adapter.py OK" || echo "⚠️ adapter.py check skipped"
python3 -m py_compile agent-zero-zulip/src/agent_zero_zulip/adapter.py && echo "✅ a2a adapter OK" python3 -m py_compile agent-zero-zulip/src/agent_zero_zulip/adapter.py 2>/dev/null && echo "✅ a2a adapter OK" || echo "⚠️ a2a check skipped"
- name: Workflow YAML parse check
run: python3 ci_check.py workflows
- name: Config validation - name: Config validation
run: python3 ci_check.py config run: |
python3 -c "
import yaml
cfg = yaml.safe_load(open('config.yaml.example'))
assert 'zulip' in cfg, 'Missing zulip config'
print('✅ config.yaml.example valid')
" 2>/dev/null || echo "⚠️ Config check skipped (no pyyaml)"
- name: No secrets check - name: No secrets check
run: python3 ci_check.py secrets run: |
! grep -r "api_key.*[A-Za-z0-9]\{20,\}" --include="*.py" --include="*.ts" --include="*.yaml" . 2>/dev/null || echo "⚠️ Possible API key found!"
- name: Deploy script syntax - name: Deploy script syntax
run: bash -n scripts/deploy.sh && echo "✅ deploy.sh syntax OK" run: bash -n scripts/deploy.sh && echo "✅ deploy.sh syntax OK" || echo "⚠️ deploy.sh syntax issue"
deploy:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: [validate]
steps:
- run: echo "🚀 Deploy tag $(echo $GITHUB_REF_NAME)"
- run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko (canary)
run: ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 jerome@192.168.68.122 "cd /root && bash -s" < scripts/deploy.sh --ct=tanko --mode=native "$GITHUB_REF_NAME" 2>&1 || echo "⚠️ Tanko deploy skipped"
- name: Deploy to Mumuni
run: ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@192.168.68.123 "cd /root && bash -s" < scripts/deploy.sh --ct=mumuni --mode=native "$GITHUB_REF_NAME" 2>&1 || echo "⚠️ Mumuni deploy skipped"
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax - name: Python syntax
run: | run: |
python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null
@@ -37,7 +37,7 @@ jobs:
environment: canary environment: canary
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko - name: Deploy to Tanko
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
@@ -66,7 +66,7 @@ jobs:
environment: production environment: production
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to all Hermes agents - name: Deploy to all Hermes agents
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
@@ -97,7 +97,7 @@ jobs:
environment: agent-zero environment: agent-zero
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to kagentz - name: Deploy to kagentz
env: env:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
-1
View File
@@ -34,4 +34,3 @@ deploy.log
# PM2 ecosystem (local configs) # PM2 ecosystem (local configs)
ecosystem.*.config.cjs ecosystem.*.config.cjs
.agents/
-1
View File
@@ -1 +0,0 @@
# CI Final Test
-27
View File
@@ -1,27 +0,0 @@
# CI Pipeline Status
**Last verified: 2026-09-25** — see PR "main security + truncate + CI repair".
## Reality check (before that PR)
`ci.yml` was **invalid YAML** — the inline `run: |` blocks for the config and
secrets checks dedented out of their block scalar, so Gitea Actions could never
parse the workflow. CI never ran on any PR, and this file's "Active" status was
fiction. The old "No secrets check" also swallowed hits with `|| echo` (always
green) and never scanned `.yml` files — which is where six embedded
credentials were living.
## Current pipeline
| Trigger | Job | Checks |
|---|---|---|
| PR → main, push main, tag `v*` | `validate` | adapter + a2a `py_compile`; workflow YAML parse; `config.yaml.example`; secret scan; `bash -n scripts/deploy.sh` |
| tag `v*` | `deploy` | Tanko canary + Mumuni via `scripts/deploy.sh` (native mode) |
All validation logic lives in **`ci_check.py`** — run `python3 ci_check.py all`
locally before pushing; it fails the check on real hits instead of echoing
warnings.
## Known caveats
- The removed credentials still exist in git history (pre-July commits) —
rotating `abiba-bot`'s password is a **server-side** task, out of repo scope.
- Runner availability (`zulip-runner` on CT 116) is not verifiable from agents
— the first green run after merge is the proof.
-129
View File
@@ -1,129 +0,0 @@
# Zulip Extension Contract Verification Report
**Date**: 2026-06-29 | **Tester**: Abiba (pi agent) | **Scope**: pi + Hermes Zulip plugins
---
## Pi Zulip Extension (`~/.pi/agent/extensions/zulip/`)
### Test Matrix
| # | Test | Result | Detail |
|---|------|--------|--------|
| 1 | Queue registration | ✅ PASS | Registers with Zulip, gets queue_id |
| 2 | Event polling | ✅ PASS | Polls every 3s, receives events |
| 3 | Own-message filtering | ✅ PASS | Correctly skips own sent messages |
| 4 | Health endpoint (:9200) | ✅ PASS | Responds with connected state, stats |
| 5 | Event reception (DM) | ✅ PASS | `idle_seconds` resets on event arrival |
| 6 | @mention detection | ✅ PASS | Bot user_id 21 resolved correctly |
| 7 | Streaming placeholder | ⚠️ UNTESTED* | Code path verified, no live DM to trigger |
| 8 | LLM response relay | ⚠️ UNTESTED* | Requires external user DM |
| 9 | Zulip commands (/status, etc) | ⚠️ UNTESTED* | Requires external user DM |
**Untested: No external user has DMed the bot since last restart.*
### Bugs Found
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| B1 | **HIGH** | `all_bots_user_id: 1` in config.yaml — actual Zulip value is **20** | ✅ **FIXED** — updated to 20 |
| B2 | MEDIUM | `last_event_id` always shows -1 in health endpoint — object property captured by value, not reference | Fix: use getter or update property in poll() |
| B3 | LOW | `display_recipient` logged as `[object Object]` — should use `JSON.stringify` | Fix: change log line |
| B4 | MEDIUM | PM2 restarts=4 in <30min — indicates crash/restart loop | Investigate cause |
| B5 | LOW | No `/zulip-test` self-test command exists | Add loopback test |
---
## Hermes Zulip Plugin (`plugins/platforms/zulip/`)
### Test Matrix
| # | Test | Result | Detail |
|---|------|--------|--------|
| 1 | Import & structure | ✅ PASS | `ZulipAdapter(BasePlatformAdapter)` OK |
| 2 | Requirements check | ✅ PASS | Env detection works |
| 3 | Adapter instantiation | ✅ PASS | Config parsed correctly |
| 4 | Self-test (pre-connect) | ✅ PASS | Reports degraded (expected, pre-connect) |
| 5 | Connect to Zulip | ✅ PASS | Queue registered, bot_id=21 resolved |
| 6 | Self-test (post-connect) | ✅ PASS | 7/8 checks pass (connected flag mock issue) |
| 7 | Health stats | ✅ PASS | Poll stats, dedup, silence tracking all work |
| 8 | @all-bots resolution | ✅ PASS | **Dynamically resolves to 20** (correct!) |
| 9 | Disconnect | ✅ PASS | Clean shutdown |
| 10 | Deployed as service | ❌ FAIL | Not deployed, no systemd service |
### Bugs Found
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| H1 | **CRITICAL** | Hermes plugin NOT deployed — no systemd service, no running instance | Create systemd service + deploy |
| H2 | MEDIUM | `httpx` not available globally — needs `pip install --break-system-packages` | Add to requirements or venv |
| H3 | LOW | selftest `connected` flag relies on `_mark_connected()` mock issue | Minor, doesn't affect real Hermes Gateway |
---
## Cross-Platform Contract Gaps
| # | Gap | Severity | Recommendation |
|---|-----|----------|----------------|
| G1 | **No unified contract test** — pi and Hermes tested independently | HIGH | Create cross-platform `.prose.md` contract |
| G2 | **@all-bots ID inconsistency** — pi hardcoded 1, Hermes dynamic-resolved to 20 | HIGH (fixed B1) | Make pi extension also dynamic-resolve |
| G3 | **No agent identity verification** — no way to confirm "this bot is Abiba" | MEDIUM | Add `/whoami` or identity stamp |
| G4 | **No cross-bot communication test** — can't verify @all-bots works end-to-end | MEDIUM | Schedule coordinated test when all bots online |
| G5 | **Health check format differs** — pi returns flat JSON, Hermes returns nested stats | LOW | Standardize health endpoint schema |
---
## Immediate Actions
1. **✅ DONE** — Fixed `all_bots_user_id: 1 → 20` in pi config
2. **TODO** — Restart pi Zulip service to pick up config change: `pm2 restart abiba-zulip`
3. **TODO** — Deploy Hermes Zulip plugin as systemd service
4. **TODO** — Fix `last_event_id` health display bug (B2)
5. **TODO** — Create OpenProse cross-platform contract for automated validation
---
## Contract Upgrade Recommendations
### 1. Dynamic @all-bots Resolution (both platforms)
Pi extension should resolve `all_bots_user_id` from Zulip API (like Hermes does) instead of hardcoding.
### 2. Standardized Health Schema
```json
{
"status": "ok",
"platform": "pi",
"agent": "abiba",
"zulip": {
"connected": true,
"queue_id": "...",
"last_event_id": 2,
"bot_user_id": 21,
"all_bots_user_id": 20,
"messages_processed": 5,
"silence_seconds": 12
},
"uptime_seconds": 3600,
"checks": {
"queue": "ok",
"poll_loop": "ok",
"self_test": "ok"
}
}
```
### 3. Built-in Self-Test Command
Add `/zulip-test` that:
- Sends a test DM to owner
- Verifies event reception
- Reports round-trip time
- Validates all code paths
### 4. Cross-Platform Contract (OpenProse)
Create a `.prose.md` contract that:
- Verifies both pi and Hermes plugins connect to Zulip
- Validates @mention and @all-bots routing
- Runs on schedule (daily) or on-demand
- Reports to knowledge graph
### 5. Queue Health Monitoring
The `last_event_id=-1` issue is actually a Zulip server behavior (fresh instance). Add monitoring that alerts if `last_event_id` doesn't advance after external messages are sent.
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""CI validation checks for zulip-platform-plugins.
The inline validation steps this script replaced were never valid YAML in the
first place (the `run: |` blocks dedented out of their block scalar), so the
whole `validate` job silently never ran. Keeping the logic in a real file
means it is testable locally — run `python3 ci_check.py all` before pushing.
Usage: python3 ci_check.py {workflows|config|secrets|all}
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CODE_GLOBS = ("*.py", "*.ts", "*.yaml", "*.yml", "*.cjs", "*.sh")
# scheme://user:pass@host — embedded HTTP Basic credentials
CRED_RE = re.compile(r"://[^/ \"']+:[^/ \"'@]+@")
API_KEY_RE = re.compile(r"""api_key["']?\s*[:=]\s*["']?[A-Za-z0-9]{20,}""")
# Allowlist: placeholder patterns, not real secrets
PLACEHOLDER = ("${", "{{", "user:pass", "USER:TOKEN", "user:token", "example")
def iter_code_files():
for path in sorted(ROOT.rglob("*")):
if not path.is_file():
continue
if any(part in {".git", "__pycache__", "node_modules"} for part in path.parts):
continue
if path.name == Path(__file__).name: # this file documents the patterns
continue
if any(path.match(glob) for glob in CODE_GLOBS):
yield path
def check_workflows() -> bool:
try:
import yaml
except ModuleNotFoundError:
print("⚠️ pyyaml not installed — workflow parse check skipped")
return True
ok = True
for f in sorted((ROOT / ".gitea" / "workflows").glob("*.yml")):
try:
doc = yaml.safe_load(f.read_text())
assert isinstance(doc, dict) and "jobs" in doc, "missing 'jobs' mapping"
print(f"✅ {f.relative_to(ROOT)} valid — jobs: {list(doc['jobs'])}")
except Exception as e:
print(f"❌ {f.relative_to(ROOT)}: {e}")
ok = False
return ok
def check_config() -> bool:
try:
import yaml
except ModuleNotFoundError:
print("⚠️ pyyaml not installed — config check skipped")
return True
try:
cfg = yaml.safe_load((ROOT / "config.yaml.example").read_text())
assert isinstance(cfg, dict) and "zulip" in cfg, "missing 'zulip' section"
print("✅ config.yaml.example valid")
return True
except Exception as e:
print(f"❌ config.yaml.example: {e}")
return False
def check_secrets() -> bool:
hits = []
for path in iter_code_files():
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
for rx in (CRED_RE, API_KEY_RE):
if rx.search(line) and not any(p in line for p in PLACEHOLDER):
hits.append(f"{path.relative_to(ROOT)}:{lineno}: {line.strip()[:100]}")
break
if hits:
print("❌ Embedded credentials detected:")
for h in hits:
print(f" {h}")
return False
print("✅ No embedded credentials in tracked code/workflow files")
return True
CHECKS = {"workflows": check_workflows, "config": check_config, "secrets": check_secrets}
def main() -> int:
args = sys.argv[1:] or ["all"]
names = list(CHECKS) if "all" in args else args
failed = [n for n in names if n not in CHECKS or not CHECKS[n]()]
if failed:
print(f"❌ CI checks failed: {', '.join(failed)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,98 +0,0 @@
---
kind: responsibility
name: zulip-platform-verification
version: 1.0.0
description: >
Cross-platform Zulip agent verification contract. Validates both pi (TypeScript
extension) and Hermes (Python BasePlatformAdapter) plugins can connect to Zulip,
receive DMs, filter own messages, resolve @all-bots, and relay LLM responses.
Designed to run on schedule (daily) or on-demand by any Syslog agent.
author: Abiba (pi agent)
---
# Zulip Platform Verification Contract
## Platforms Under Contract
| Platform | Agent | Host | Bot Email | Health URL |
|----------|-------|------|-----------|------------|
| pi | Abiba | `192.168.68.24` | abiba-bot@chat.sysloggh.net | `http://192.168.68.24:9200/health` |
| Hermes | Tanko | `192.168.68.123` | tanko-bot@chat.sysloggh.net | `http://192.168.68.123:9201/health` |
| Hermes | Mumuni | `192.168.68.123` | mumuni-bot@chat.sysloggh.net | `http://192.168.68.123:9202/health` |
| Hermes | Koonimo | `192.168.68.123` | koonimo-bot@chat.sysloggh.net | `http://192.168.68.123:9203/health` |
| Hermes | Koby | `192.168.68.123` | koby-bot@chat.sysloggh.net | `http://192.168.68.123:9204/health` |
## Parameters
- zulip_site: string — Zulip server URL (default: "https://chat.sysloggh.net")
- agents: array — List of agent configs to verify (default: all)
- test_message: string — Content to send in loopback DM (default: "🔬 Contract self-test")
## Checks (per agent)
### Check 1: Health Endpoint
- GET {{health_url}} → expect 200, `{ status: "ok", connected: true }`
### Check 2: Queue Registered
- Health response must include `queue_id` (non-null string)
### Check 3: Bot Identity Resolved
- Health response must include `bot_user_id` (positive integer, not null)
### Check 4: @all-bots Resolved
- Health response must include `all_bots_user_id` (positive integer, not null)
- Must match the actual Zulip @all-bots user (resolve from `/api/v1/users`)
### Check 5: Poll Loop Active
- `idle_seconds` < 300 (5 min) OR `stuck` = false
- `silence_seconds` < 600 (10 min) for Hermes adapter
### Check 6: Event Reception (Loopback)
- Send test DM via Zulip API from external account
- Verify `idle_seconds` resets to <10s within 5s of DM
- OR verify `messages_processed` increments (for external-source DMs)
### Check 7: Self-Test (if available)
- Hermes: POST to adapter's `selftest()` method → expect verdict "healthy"
- pi: Run `/zulip-test` command via RPC → expect "All checks passed"
### Check 8: Echo-Loop Prevention
- Health endpoint must include mechanism to identify bot email
- Own-sent messages must not trigger processing
## Returns
```json
{
"verdict": "healthy" | "degraded" | "critical",
"timestamp": "ISO-8601",
"agents": {
"abiba": {
"platform": "pi",
"verdict": "healthy",
"checks": { ... },
"details": { ... }
},
"tanko": {
"platform": "hermes",
"verdict": "degraded",
"checks": { ... },
"details": { ... }
}
},
"summary": {
"total": 5,
"healthy": 4,
"degraded": 1,
"critical": 0
}
}
```
## Maintains
- Each agent is independently verified; one failure doesn't block others
- @all-bots user_id is cross-validated against Zulip API (catches config drift)
- Report is saved to vault at `/root/vault/contract-reports/zulip-verification-{date}.md`
- Alert sent to owner if any agent is critical or >2 are degraded
- Contract is self-documenting — results include raw health snapshots
@@ -1,76 +1,26 @@
/**
* PM2 Ecosystem Config — Zulip Gateway v3 (Resilience)
*
* Deploy: pm2 start /root/.pm2/ecosystem.config.cjs
* Status: pm2 status
* Logs: pm2 logs abiba-zulip
*/
module.exports = { module.exports = {
apps: [ apps: [
{ {
// ── Router (main Zulip gateway) ──
name: "abiba-zulip", name: "abiba-zulip",
script: "/bin/pi", script: "/usr/bin/pi",
args: "--mode rpc --session-id zulip-service", args: "--mode rpc --session-id zulip-service",
cwd: "/root", cwd: "/root",
// Resilience hardening — up from pi defaults
max_restarts: 100, // Crash loops won't exhaust PM2 (was default 10)
min_uptime: "10s", // Must survive 10s to count as "alive"
max_memory_restart: "500M", // OOM protection — restart before swap thrash
restart_delay: 5000, // 5s cooldown between restarts
kill_timeout: 15000, // 15s SIGTERM grace before SIGKILL
listen_timeout: 30000, // 30s to bind health port
// Logging
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/root/.pm2/logs/abiba-zulip-error.log",
out_file: "/root/.pm2/logs/abiba-zulip-out.log",
merge_logs: true,
log_type: "json",
// Process management
autorestart: true,
watch: false,
instances: 1,
exec_mode: "fork",
// Environment
env: { env: {
ZULIP_ROLE: "router", ZULIP_ROLE: "router",
ZULIP_SITE: "https://chat.sysloggh.net", ZULIP_EXTENSION_ACTIVE: "true",
ZULIP_EMAIL: "abiba-bot@chat.sysloggh.net", NODE_OPTIONS: "--max-old-space-size=512",
ZULIP_API_KEY: "cKTDMZAPW08dk3zl05sStzO7HRztzyn8",
AGENT_NAME: "abiba",
AGENT_OWNER_EMAIL: "jerome@sysloggh.com",
NODE_ENV: "production",
}, },
// Prevent rapid crash-looping: restart with backoff, limit retries
min_uptime: "30s",
max_restarts: 15,
restart_delay: 10000,
kill_timeout: 5000,
autorestart: true,
}, },
{ {
// ── Supervisor (external watchdog) ── name: "abiba-telegram",
name: "zulip-watchdog", script: "/usr/bin/pitg",
script: "/root/.pi/agent/extensions/zulip/watchdog.js",
cwd: "/root", cwd: "/root",
max_restarts: 10,
min_uptime: "3s",
restart_delay: 3000,
kill_timeout: 5000,
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/root/.pm2/logs/zulip-watchdog-error.log",
out_file: "/root/.pm2/logs/zulip-watchdog-out.log",
merge_logs: true,
autorestart: true,
watch: false,
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
},
}, },
], ],
}; };
-11
View File
@@ -1,11 +0,0 @@
{
"lastChangelogVersion": "0.80.6",
"defaultProvider": "syslog-harness",
"defaultModel": "syslog-auto",
"defaultThinkingLevel": "high",
"extensions": [
"+extensions/mcp/index.ts",
"+extensions/zulip/index.js"
],
"theme": "dark"
}
@@ -1,24 +0,0 @@
# pi Zulip extension — Abiba configuration (flat format for custom parser)
# Deploy to: ~/.pi/agent/extensions/config.yaml
zulip.site: https://chat.sysloggh.net
zulip.email: abiba-bot@chat.sysloggh.net
zulip.api_key: YOUR_ZULIP_API_KEY_HERE
zulip.stream: agent-hub
zulip.all_bots_user_id: 20
agent.name: abiba
agent.display_name: Abiba
agent.zulip_bot_name: abiba-bot
agent.owner_email: jerome@sysloggh.com
agent.private_topic: abiba
health_port: 9200
monitoring.health_endpoint_enabled: true
monitoring.log_level: info
error_handling.timeout_seconds: 30
error_handling.retry_count: 3
error_handling.retry_delay_seconds: 5
error_handling.graceful_message: "Abiba encountered an error processing your request. Please try again later."
-34
View File
@@ -1,34 +0,0 @@
// Type declarations for zulip-js (no @types available)
declare module "zulip-js" {
interface ZulipClient {
queues: {
register(params: {
event_types: string[];
narrow?: Array<Array<string | number>>;
}): Promise<{ queue_id: string; last_event_id: number }>;
};
users: {
me: {
get(): Promise<any>;
};
};
messages: {
store: {
send(rawContent: string): Promise<any>;
};
};
events: {
retrieve(params: {
queue_id: string;
last_event_id: number;
dont_block?: boolean;
}): Promise<{ events: any[]; result?: string; msg?: string }>;
};
}
export default function zulip(config: {
username: string;
apiKey: string;
realm: string;
}): Promise<ZulipClient>;
}
@@ -1,84 +0,0 @@
/**
* Zulip Gateway Supervisor — external watchdog process.
*
* Monitors the router health endpoint every 30s. If 3 consecutive checks fail,
* restarts the abiba-zulip PM2 process gracefully.
*
* This is the pattern Hermes uses: an external supervisor that can recover
* the gateway even when the gateway process itself is hung (not just crashed).
*
* Deployed via PM2 as a separate process in ecosystem.config.cjs.
*/
const HEALTH_URL = "http://127.0.0.1:9200/health";
const CHECK_INTERVAL_MS = 30_000;
const MAX_FAILURES = 3;
const RESTART_GRACE_MS = 15_000;
let failures = 0;
async function check() {
try {
const res = await fetch(HEALTH_URL, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const data = await res.json();
if (data.status === "ok" && data.zulip?.connected) {
if (failures > 0) {
console.log(`[watchdog] Router recovered after ${failures} failure(s)`);
}
failures = 0;
// Silent health log every 10 checks (~5 min) for monitoring
if (Math.random() < 0.1) {
console.log(`[watchdog] Router healthy (uptime: ${Math.round(process.uptime())}s)`);
}
return;
}
// Connected but degraded
console.warn(`[watchdog] Router degraded: status=${data.status}, connected=${data.zulip?.connected}`);
failures++;
} else {
console.warn(`[watchdog] Health check returned ${res.status}`);
failures++;
}
} catch (err) {
failures++;
console.warn(`[watchdog] Health check ${failures}/${MAX_FAILURES}: ${err.message}`);
}
if (failures >= MAX_FAILURES) {
console.error(`[watchdog] ${MAX_FAILURES} consecutive failures — restarting abiba-zulip`);
const { execSync } = await import("node:child_process");
try {
execSync("pm2 restart abiba-zulip", { timeout: 30000, encoding: "utf-8" });
console.log("[watchdog] Restart command sent successfully");
} catch (e) {
console.error(`[watchdog] Restart failed: ${e.message}`);
// Fallback: try resurrect if restart fails (process may be deleted)
try {
execSync("pm2 resurrect", { timeout: 30000 });
console.log("[watchdog] PM2 resurrected (fallback)");
} catch (e2) {
console.error(`[watchdog] Resurrect also failed: ${e2.message}`);
}
}
failures = 0;
// Wait for restart to fully initialize before checking again
await new Promise((r) => setTimeout(r, RESTART_GRACE_MS));
}
}
console.log("[watchdog] Zulip gateway supervisor started");
console.log(`[watchdog] Monitoring ${HEALTH_URL} every ${CHECK_INTERVAL_MS / 1000}s`);
console.log(`[watchdog] Max failures before restart: ${MAX_FAILURES}`);
// Immediate first check, then periodic
check();
setInterval(check, CHECK_INTERVAL_MS);
File diff suppressed because it is too large Load Diff
+6 -124
View File
@@ -64,14 +64,9 @@ logger = logging.getLogger(__name__)
# Constants # Constants
DEFAULT_STREAM = "agent-hub" DEFAULT_STREAM = "agent-hub"
# SyslogGH realm: the @all-bots user has user_id=20 (verified via DEFAULT_ALL_BOTS_USER_ID = 1
# /api/v1/users and CONTRACT_VERIFICATION_2026-06-29). Dynamic resolution
# on connect overrides this; this value is the fallback when that fails —
# user_id=1 was never valid in this realm and silently dropped @all-bots.
DEFAULT_ALL_BOTS_USER_ID = 20
DEFAULT_POLL_INTERVAL = 3.0 DEFAULT_POLL_INTERVAL = 3.0
MAX_ZULIP_MESSAGE = 10000 MAX_ZULIP_MESSAGE = 10000
TRUNCATION_NOTICE = "\n\n[...truncated at Zulip limit]"
ECHO_TAG_PREFIX = "hermes-zulip-" ECHO_TAG_PREFIX = "hermes-zulip-"
RECONNECT_BACKOFF = [2, 5, 10, 30, 60] RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
DEDUP_WINDOW = 300 # 5 minutes DEDUP_WINDOW = 300 # 5 minutes
@@ -82,7 +77,7 @@ SELFTEST_TIMEOUT = 30 # seconds to wait for self-test response
# Gen 4: Connection pool health # Gen 4: Connection pool health
HEARTBEAT_INTERVAL = 300 # Log heartbeat every 5 minutes HEARTBEAT_INTERVAL = 300 # Log heartbeat every 5 minutes
MAX_CONSECUTIVE_EMPTY_POLLS = 500 # Reset client after this many empty polls (~25min at 3s interval). Raised from 20 to prevent idle bots from cycling reconnections. MAX_CONSECUTIVE_EMPTY_POLLS = 20 # Reset client after this many empty polls
CLIENT_REUSE_LIMIT = 500 # Create fresh client every N polls to prevent connection leaks CLIENT_REUSE_LIMIT = 500 # Create fresh client every N polls to prevent connection leaks
POLL_SILENCE_WARN_INTERVAL = 60 # Warn if no events received for this many seconds POLL_SILENCE_WARN_INTERVAL = 60 # Warn if no events received for this many seconds
MAX_ERROR_LOG_LEN = 80 # Truncate error response bodies to avoid HTML spam MAX_ERROR_LOG_LEN = 80 # Truncate error response bodies to avoid HTML spam
@@ -104,28 +99,11 @@ def _build_auth_header(email: str, api_key: str) -> str:
return f"Basic {token}" return f"Basic {token}"
def _strip_html(text: str) -> str:
"""Strip HTML tags and decode HTML entities from Zulip message content.
Zulip delivers message content as rendered HTML (e.g. <p>/approve</p>).
The gateway slash command parser expects plain text, so HTML tags
prevent command matching. This helper strips tags and decodes entities.
"""
if not text:
return text
# Strip HTML tags
text = re.sub(r"<[^>]+>", "", text)
# Decode common HTML entities
text = text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
text = text.replace("&quot;", '"').replace("&#39;", "'").replace("&nbsp;", " ")
return text.strip()
def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str: def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str:
"""Truncate to Zulip's message limit with notice.""" """Truncate to Zulip's message limit with notice."""
if len(text) <= limit: if len(text) <= limit:
return text return text
return text[:limit - len(TRUNCATION_NOTICE)] + TRUNCATION_NOTICE return text[:limit] + "\n\n[...truncated at Zulip limit]"
def _parse_zulip_timestamp(ts: Any) -> datetime: def _parse_zulip_timestamp(ts: Any) -> datetime:
@@ -324,9 +302,6 @@ class ZulipAdapter(BasePlatformAdapter):
# Gen 2: Try to resolve @all-bots user ID dynamically # Gen 2: Try to resolve @all-bots user ID dynamically
await self._resolve_all_bots_user_id() await self._resolve_all_bots_user_id()
# Gen 5: Ensure bot is subscribed to primary stream
await self._ensure_stream_subscriptions()
self._mark_connected() self._mark_connected()
logger.info( logger.info(
"[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)", "[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)",
@@ -474,10 +449,6 @@ class ZulipAdapter(BasePlatformAdapter):
except Exception: except Exception:
pass pass
# Clear stale errors on successful poll cycle
self._health_stats["last_error_msg"] = None
self._health_stats["last_error_at"] = None
# Gen 4: Heartbeat logging — prove poll loop is alive # Gen 4: Heartbeat logging — prove poll loop is alive
now = time.time() now = time.time()
if now - self._last_heartbeat_time > HEARTBEAT_INTERVAL: if now - self._last_heartbeat_time > HEARTBEAT_INTERVAL:
@@ -706,64 +677,6 @@ class ZulipAdapter(BasePlatformAdapter):
# Gen 2: Dynamic @all-bots resolution # Gen 2: Dynamic @all-bots resolution
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _ensure_stream_subscriptions(self) -> None:
"""Ensure the bot is subscribed to the primary streams.
Checks current subscriptions and subscribes to the configured primary
stream (and general-chat) if missing. Without subscription, the bot
receives DMs but no stream events — @mentions in streams are invisible.
This is a Gen 5 improvement based on lessons from the pi Zulip extension,
where the bot had zero stream subscriptions and couldn't receive
@mentions in stream topics.
"""
try:
# Check current subscriptions
resp, _ = await self._api_call(
"GET", "/api/v1/users/me/subscriptions",
)
if not resp:
return
subscribed = resp.get("subscriptions", [])
subscribed_names = [s.get("name", "") for s in subscribed if isinstance(s, dict)]
# Streams the bot should be subscribed to
required_streams = [self._stream, "general chat"]
missing = [s for s in required_streams if s not in subscribed_names]
if not missing:
logger.info(
"[%s] Stream subscriptions OK: %s",
self.name, ", ".join(subscribed_names),
)
return
# Subscribe to missing streams
import json as _json
payload = {"subscriptions": _json.dumps(
[{"name": s} for s in missing]
)}
sub_resp, _ = await self._api_call(
"POST", "/api/v1/users/me/subscriptions",
data=payload,
)
if sub_resp and sub_resp.get("result") == "success":
subscribed_str = ", ".join(missing)
logger.info(
"[%s] Subscribed to: %s", self.name, subscribed_str,
)
self._health_stats["subscriptions_fixed"] = (
self._health_stats.get("subscriptions_fixed", 0) + 1
)
else:
logger.warning(
"[%s] Failed to subscribe to %s",
self.name, ", ".join(missing),
)
except Exception as e:
logger.warning("[%s] Subscription check failed: %s", self.name, e)
async def _resolve_all_bots_user_id(self) -> None: async def _resolve_all_bots_user_id(self) -> None:
"""Try to resolve the @all-bots user ID from the Zulip server. """Try to resolve the @all-bots user ID from the Zulip server.
@@ -806,8 +719,6 @@ class ZulipAdapter(BasePlatformAdapter):
sender_name = msg.get("sender_full_name", "Unknown") sender_name = msg.get("sender_full_name", "Unknown")
sender_id = msg.get("sender_id") sender_id = msg.get("sender_id")
content = msg.get("content", "") content = msg.get("content", "")
# Strip Zulip HTML for slash command matching (zulip-approval-fix contract)
content = _strip_html(content)
# Echo-loop prevention: skip own messages # Echo-loop prevention: skip own messages
if sender_email == self._bot_email: if sender_email == self._bot_email:
@@ -1185,35 +1096,6 @@ class ZulipAdapter(BasePlatformAdapter):
"detail": f"@all-bots user_id: {self._all_bots_user_id}", "detail": f"@all-bots user_id: {self._all_bots_user_id}",
} }
# 9. Stream subscriptions (verifies bot is subscribed to at least one stream)
try:
from urllib.parse import urlencode
import json as _json
key_str = f"{self._email}:{self._api_key}"
auth_b64 = base64.b64encode(key_str.encode()).decode()
resp, _ = await self._api_call(
"GET", "/api/v1/users/me/subscriptions",
)
if resp and "subscriptions" in resp:
stream_count = len(resp["subscriptions"])
stream_names = [s.get("name", "?") for s in resp["subscriptions"]][:5]
checks["stream_subscriptions"] = {
"status": stream_count > 0,
"detail": f"{stream_count} stream(s): {', '.join(stream_names)}"
if stream_count > 0
else "No stream subscriptions — bot will not receive stream events",
}
else:
checks["stream_subscriptions"] = {
"status": False,
"detail": "Failed to check subscriptions",
}
except Exception as sub_err:
checks["stream_subscriptions"] = {
"status": False,
"detail": f"Subscription check failed: {sub_err}",
}
# Overall verdict # Overall verdict
critical = ["connected", "queue_registered", "http_client", "poll_loop"] critical = ["connected", "queue_registered", "http_client", "poll_loop"]
passed = sum(1 for c in checks.values() if c["status"]) passed = sum(1 for c in checks.values() if c["status"])
@@ -1429,7 +1311,7 @@ def check_requirements() -> bool:
"""Check whether the Zulip adapter is installable.""" """Check whether the Zulip adapter is installable."""
if not HTTPX_AVAILABLE: if not HTTPX_AVAILABLE:
return False return False
site = os.getenv("ZULIP_SITE", "").strip() or os.getenv("ZULIP_URL", "").strip() site = os.getenv("ZULIP_SITE", "").strip()
email = os.getenv("ZULIP_EMAIL", "").strip() email = os.getenv("ZULIP_EMAIL", "").strip()
api_key = os.getenv("ZULIP_API_KEY", "").strip() api_key = os.getenv("ZULIP_API_KEY", "").strip()
return bool(site and email and api_key) return bool(site and email and api_key)
@@ -1438,7 +1320,7 @@ def check_requirements() -> bool:
def validate_config(config) -> bool: def validate_config(config) -> bool:
"""Validate that the configured Zulip platform has credentials.""" """Validate that the configured Zulip platform has credentials."""
extra = getattr(config, "extra", {}) or {} extra = getattr(config, "extra", {}) or {}
site = extra.get("site") or os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "") site = extra.get("site") or os.getenv("ZULIP_SITE", "")
email = extra.get("email") or os.getenv("ZULIP_EMAIL", "") email = extra.get("email") or os.getenv("ZULIP_EMAIL", "")
api_key = extra.get("api_key") or os.getenv("ZULIP_API_KEY", "") api_key = extra.get("api_key") or os.getenv("ZULIP_API_KEY", "")
return bool(site and email and api_key) return bool(site and email and api_key)
@@ -1451,7 +1333,7 @@ def is_connected(config) -> bool:
def _env_enablement() -> Optional[dict]: def _env_enablement() -> Optional[dict]:
"""Seeds PlatformConfig.extra from env vars for env-only setups.""" """Seeds PlatformConfig.extra from env vars for env-only setups."""
site = os.getenv("ZULIP_SITE", "").strip() or os.getenv("ZULIP_URL", "").strip() site = os.getenv("ZULIP_SITE", "").strip()
email = os.getenv("ZULIP_EMAIL", "").strip() email = os.getenv("ZULIP_EMAIL", "").strip()
api_key = os.getenv("ZULIP_API_KEY", "").strip() api_key = os.getenv("ZULIP_API_KEY", "").strip()
if not (site and email and api_key): if not (site and email and api_key):
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""
Deploy Hermes Zulip plugin as a systemd service.
Creates service file, installs dependencies, deploys plugin.
Usage: python3 deploy-hermes-zulip.py --agent tanko --email tanko-bot@chat.sysloggh.net --api-key KEY
"""
import argparse, os, sys, subprocess, json
SYSTEMD_TEMPLATE = """[Unit]
Description=Hermes Zulip Gateway — {agent_name} ({bot_email})
After=network-online.target
Wants=network-online.target
Documentation=https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins
[Service]
Type=simple
User=root
WorkingDirectory=/opt/hermes-zulip-{agent_name}
Environment=ZULIP_SITE=https://chat.sysloggh.net
Environment=ZULIP_EMAIL={bot_email}
Environment=ZULIP_API_KEY={api_key}
Environment=ZULIP_AGENT_NAME={agent_name}
Environment=ZULIP_STREAM=agent-hub
Environment=ZULIP_POLL_INTERVAL=3
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -m hermes_zulip.adapter_standalone
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hermes-zulip-{agent_name}
[Install]
WantedBy=multi-user.target
"""
PLUGIN_FILES = [
"plugins/platforms/zulip/__init__.py",
"plugins/platforms/zulip/adapter.py",
"plugins/platforms/zulip/plugin.yaml",
]
def main():
parser = argparse.ArgumentParser(description="Deploy Hermes Zulip plugin")
parser.add_argument("--agent", required=True, help="Agent name (e.g. tanko)")
parser.add_argument("--email", required=True, help="Bot email")
parser.add_argument("--api-key", required=True, help="Zulip API key")
parser.add_argument("--port", type=int, default=None, help="Health port (auto-assigned)")
args = parser.parse_args()
agent = args.agent.lower()
port = args.port or (9201 + hash(agent) % 10)
repo_dir = "/root/zulip-platform-plugins"
deploy_dir = f"/opt/hermes-zulip-{agent}"
print(f"=== Deploying Hermes Zulip Plugin: {agent} ===")
print(f" Bot: {args.email}")
print(f" Port: {port}")
print(f" Deploy dir: {deploy_dir}")
print()
# 1. Ensure httpx is installed
print("[1/5] Installing httpx...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--quiet", "httpx"],
check=False,
)
print(" ✅ httpx installed")
# 2. Create deploy directory
print(f"[2/5] Setting up {deploy_dir}...")
os.makedirs(deploy_dir, exist_ok=True)
# 3. Copy plugin files
print("[3/5] Copying plugin files...")
for f in PLUGIN_FILES:
src = os.path.join(repo_dir, f)
dst = os.path.join(deploy_dir, os.path.basename(f))
if os.path.exists(src):
subprocess.run(["cp", src, dst], check=True)
print(f" ✅ {os.path.basename(f)}")
else:
print(f" ⚠️ {f} not found, skipping")
# 4. Create standalone adapter launcher
print("[4/5] Creating adapter launcher...")
launcher = os.path.join(deploy_dir, "adapter_standalone.py")
with open(launcher, "w") as f:
f.write(f'''"""Standalone launcher for Hermes Zulip adapter — {agent}."""
import asyncio, os, sys, logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
sys.path.insert(0, "{deploy_dir}")
from adapter import ZulipAdapter, check_requirements
async def main():
if not check_requirements():
print("Missing Zulip credentials. Set ZULIP_SITE, ZULIP_EMAIL, ZULIP_API_KEY.")
sys.exit(1)
config = type("Config", (), {{"extra": {{}}}})()
adapter = ZulipAdapter(config)
ok = await adapter.connect()
if not ok:
print("Failed to connect to Zulip")
sys.exit(1)
print(f"Connected as {{adapter._email}} (queue={{adapter._queue_id}})")
# Keep alive
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await adapter.disconnect()
if __name__ == "__main__":
asyncio.run(main())
''')
print(" ✅ adapter_standalone.py")
# 5. Create and enable systemd service
print("[5/5] Installing systemd service...")
service_name = f"hermes-zulip-{agent}"
service_content = SYSTEMD_TEMPLATE.format(
agent_name=agent.title(),
bot_email=args.email,
api_key=args.api_key,
port=port,
)
service_path = f"/etc/systemd/system/{service_name}.service"
with open(service_path, "w") as f:
f.write(service_content)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", service_name], check=True)
print(f" ✅ Service installed: {service_name}")
print(f" Service file: {service_path}")
print()
print(f" To start: systemctl start {service_name}")
print(f" To check: systemctl status {service_name}")
print(f" Logs: journalctl -u {service_name} -f")
if __name__ == "__main__":
main()