The agent command is configurable (agent.command in config.yaml, default hermes chat). Message passed via stdin, response read from stdout.
Escalation path (future)
This uses subprocess (same limitation the pi extension had initially). Once Hermes agents expose an IPC/socket API, the adapter can switch to direct communication — the _invoke_agent method is the only place that needs changing.
Complete rewrite of the Hermes Zulip adapter, following the same architecture validated in pi-zulip-extension (PR #22, #23).
## What was broken
- Adapter connected to Zulip but **never routed messages to the agent** and **never sent responses back**
- Used deprecated `call_on_each_message` in a fragile daemon thread
- No event queue polling, no retry/reconnect, no typing indicators
## What changed
| Feature | Before | After |
|---------|--------|-------|
| Event processing | Threaded `call_on_each_message` | Async event queue poll loop |
| Agent routing | None (returned parsed dict, did nothing) | Subprocess invocation via `agent.command` |
| Response to Zulip | None | Placeholder → edit with final response |
| Typing indicators | None | Start/stop via REST API |
| Reconnection | None | Exponential backoff with retry |
| Health endpoint | Inline http.createServer in adapter | Background thread HTTP server |
| CLI entry | None | `python3 -m hermes_zulip --config config.yaml` |
## Architecture
```
Zulip event queue → async poll loop → parse @mention/DM
→ spawn agent subprocess (stdin) → capture stdout
→ edit placeholder with final response → done
```
The agent command is configurable (`agent.command` in config.yaml, default `hermes chat`). Message passed via stdin, response read from stdout.
## Escalation path (future)
This uses subprocess (same limitation the pi extension had initially). Once Hermes agents expose an IPC/socket API, the adapter can switch to direct communication — the `_invoke_agent` method is the only place that needs changing.
Gen 3 improvements from build-zulip-plugin contract run:
1. Malformed message resilience
- Per-event try/except in poll loop — one bad event never kills adapter
- malformed_events counter tracked in health stats
- Fulfills Success Criterion: handles_malformed_messages == true
2. Periodic @all-bots refresh
- _all_bots_refresh_forever() background task re-resolves every hour
- Cancelled gracefully on disconnect()
- all_bots_refreshes counter in health stats
3. Health stats callback mechanism
- set_health_callback(callback, interval=600) — register external consumer
- _report_health_if_callback() triggers on dedup cleanup cycle
- Designed for Hermes Gateway to wire to RA-H OS knowledge graph logging
- Cross-contract integration: get_all_bots_user_id() and get_bot_user_id()
exposed for zulip-mention-reliability contract
Root cause of 'str' object has no attribute 'value' error during connect():
the adapter passed a raw string 'zulip' to the base class's platform
parameter, which expects a Platform enum instance. When self.name was
accessed (calling self.platform.value.title()), the string had no .value
attribute.
Fix: import Platform from gateway.config and wrap with Platform('zulip').
Root cause of Tanko not responding to DMs after initial connection:
Zulip event queues expire after ~10min of dont_block polling. When the
queue expired, _api_call() returned None for all 400-level errors, which
_fetch_events() treated as 'no events' and returned []. The poll loop
never knew the queue died, so it never reconnected.
Fix:
1. _api_call() now returns (Optional[Dict], status_code) tuple
2. _fetch_events() explicitly checks for 400 status and raises
RuntimeError('BAD_EVENT_QUEUE_ID') for the poll loop to catch
3. All callers updated to unpack the tuple
Now when a queue expires, the poll loop catches the error and
calls _reconnect() to register a fresh queue.
The /register endpoint doesn't always return user_id on all Zulip
server versions. Added _resolve_bot_user_id() that queries the
/users/me endpoint as a fallback. This ensures @mention detection
(is_direct_mention check) works even when register doesn't provide it.
Also bumped to v1.0.3 — cumulative fixes since v1.0.1:
- v1.0.2: BAD_EVENT_QUEUE_ID detection (silent queue expiry)
- v1.0.3: bot user_id resolution (@mention detection)
SendResult only accepts: success, message_id, error, raw_response,
retryable. The adapter was passing platform=, chat_id=, and metadata=
which are not valid fields, causing TypeError on every send attempt.
This was the error Tanko was showing in Zulip:
'SendResult.__init__() got an unexpected keyword argument "platform"'
Fixes 4 critical reliability issues discovered during Zulip server outage recovery:
1. CLOSE-WAIT socket leak: _reconnect() now creates a fresh httpx.AsyncClient()
when the queue expires, preventing stuck connection pool. Old client is
explicitly aclose()'d.
2. Silent poll loop death: After sustained 502 errors, the poll loop was
returning [] without logging — transport errors are swallowed by _api_call
returning (None, 0). Added heartbeat logging every 5 minutes and silence
detection warnings after 60s of no events.
3. HTTP client stuck pool: Added proactive client recreation after 20 consecutive
empty polls (connection pool exhaustion detection) and periodic refresh every
500 polls (connection reuse limit).
4. Log spam: 502 error responses from Netbird contain full HTML pages — now
truncated to 80 chars with newlines stripped.
Also:
- Support ZULIP_URL env var (in addition to ZULIP_SITE)
- expose silence_seconds, consecutive_empty_polls, client_pool_resets in health stats
- Reduced health callback interval from 600s to 300s
The Gateway streaming interface calls edit_message() with finalize=True
and expects SendResult. Fixed:
1. Added **kwargs to edit_message() signature — silently ignores finalize
2. Changed return type from bool to SendResult for Gateway compatibility
3. Switched from PATCH to POST /api/v1/messages/{id} — Zulip docs use
POST for message editing (PATCH returns 405 Method Not Allowed)
4. Same fixes applied to delete_message() and send()
Discovered during Tanko Gen 4 testing.
Zulip API edits messages via PATCH /api/v1/messages/{message_id}
(POST returns 405 on this endpoint). Also:
- edit_message() now returns SendResult (not bool) for Gateway compatibility
- Clean up duplicate gateway instances before restart
A2A server uses Agent Zero's AgentContext.communicate() directly
to process Zulip messages inside the container.
Full chain verified: Zulip DM -> adapter -> A2A -> Agent Zero -> response -> Zulip
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Complete rewrite of the Hermes Zulip adapter, following the same architecture validated in pi-zulip-extension (PR #22, #23).
What was broken
call_on_each_messagein a fragile daemon threadWhat changed
call_on_each_messageagent.commandpython3 -m hermes_zulip --config config.yamlArchitecture
The agent command is configurable (
agent.commandin config.yaml, defaulthermes chat). Message passed via stdin, response read from stdout.Escalation path (future)
This uses subprocess (same limitation the pi extension had initially). Once Hermes agents expose an IPC/socket API, the adapter can switch to direct communication — the
_invoke_agentmethod is the only place that needs changing.Gen 2 improvements from build-zulip-plugin contract run: 1. Background dedup maintenance task - _dedup_cleanup_forever() runs every 120s, pruning stale entries - _is_duplicate() is now pure O(1) — no per-call cleanup overhead - Cleanup is cancelled gracefully on disconnect 2. Self-test diagnostics (selftest()) - 8 checks: connection, queue, HTTP client, bot identity, poll loop, dedup cleanup, echo prevention, @all-bots configuration - Returns structured verdict: healthy / degraded / critical_failure - Verifies every Success Criterion from the contract 3. Health stats tracking (get_health_stats()) - Uptime, poll counts/errors, message routing counts, send stats - Error rate computation, dedup map size - Suitable for periodic RA-H OS knowledge graph logging 4. Dynamic @all-bots resolution - _resolve_all_bots_user_id() queries Zulip /api/v1/users on connect - Falls back to configured env var or hardcoded default (1) - Eliminates fragile hardcoded default in multi-bot deployments 5. Connection lifecycle now manages both poll_task and dedup_cleanup_task - Both cancelled gracefully on disconnect() - dedup task starts in connect() after queue registrationGen 3 improvements from build-zulip-plugin contract run: 1. Malformed message resilience - Per-event try/except in poll loop — one bad event never kills adapter - malformed_events counter tracked in health stats - Fulfills Success Criterion: handles_malformed_messages == true 2. Periodic @all-bots refresh - _all_bots_refresh_forever() background task re-resolves every hour - Cancelled gracefully on disconnect() - all_bots_refreshes counter in health stats 3. Health stats callback mechanism - set_health_callback(callback, interval=600) — register external consumer - _report_health_if_callback() triggers on dedup cleanup cycle - Designed for Hermes Gateway to wire to RA-H OS knowledge graph logging - Cross-contract integration: get_all_bots_user_id() and get_bot_user_id() exposed for zulip-mention-reliability contractRoot cause of 'str' object has no attribute 'value' error during connect(): the adapter passed a raw string 'zulip' to the base class's platform parameter, which expects a Platform enum instance. When self.name was accessed (calling self.platform.value.title()), the string had no .value attribute. Fix: import Platform from gateway.config and wrap with Platform('zulip').Root cause of Tanko not responding to DMs after initial connection: Zulip event queues expire after ~10min of dont_block polling. When the queue expired, _api_call() returned None for all 400-level errors, which _fetch_events() treated as 'no events' and returned []. The poll loop never knew the queue died, so it never reconnected. Fix: 1. _api_call() now returns (Optional[Dict], status_code) tuple 2. _fetch_events() explicitly checks for 400 status and raises RuntimeError('BAD_EVENT_QUEUE_ID') for the poll loop to catch 3. All callers updated to unpack the tuple Now when a queue expires, the poll loop catches the error and calls _reconnect() to register a fresh queue.The Gateway streaming interface calls edit_message() with finalize=True and expects SendResult. Fixed: 1. Added **kwargs to edit_message() signature — silently ignores finalize 2. Changed return type from bool to SendResult for Gateway compatibility 3. Switched from PATCH to POST /api/v1/messages/{id} — Zulip docs use POST for message editing (PATCH returns 405 Method Not Allowed) 4. Same fixes applied to delete_message() and send() Discovered during Tanko Gen 4 testing.Zulip API edits messages via PATCH /api/v1/messages/{message_id} (POST returns 405 on this endpoint). Also: - edit_message() now returns SendResult (not bool) for Gateway compatibility - Clean up duplicate gateway instances before restartNew project: agent-zero-zulip/ — direct Zulip integration for Agent Zero agents using A2A protocol internally. Architecture: kagentz (CT 105) ┌──────────────────────┐ │ Zulip Adapter ◄──► │ Agent Zero │ polls Zulip A2A │ Docker container │ port 8001 │ └──────────────────────┘ - adapter.py: Zulip event queue poller + A2A client - No intermediate bridge — direct kagentz ↔ Zulip - Heartbeat, reconnection, placeholder→edit streaming - Designed for re-use: scottdenya, future Agent Zero agentsPull request closed