feat(gateway): Google Chat platform adapter as a bundled plugin#18425
feat(gateway): Google Chat platform adapter as a bundled plugin#18425donramon77 wants to merge 18 commits into
Conversation
…ts via user OAuth Adds full Google Chat support to hermes-agent's platform layer with feature parity to Telegram/Slack: Platform adapter (gateway/platforms/google_chat.py): - Pub/Sub pull subscription for inbound events (no public endpoint). - Chat REST API for outbound messages, async-wrapped via to_thread. - Supervisor task with exponential backoff for streaming-pull resilience. - Inbound attachment download with SSRF guard on downloadUri (Google-host allowlist + IMDS rejection). - Dedup ring buffer (Pub/Sub is at-least-once). - Authorization gate: GOOGLE_CHAT_ALLOWED_USERS / GOOGLE_CHAT_ALLOW_ALL_USERS, with email-match fallback via user_id_alt. Tool/progress visibility (parity with Telegram/Slack): - edit_message + delete_message implemented via messages.patch / .delete. - Without these the gateway short-circuits the progress queue at gateway/run.py:10199 and users see no tool-progress bubbles. Tombstone fix: - Original stop_typing called messages.delete on the "Hermes is thinking…" marker, leaving "Message deleted by its author" tombstones in chat. - Restructured: stop_typing is NO-OP for live message_ids (lets send() patch them in-place); send() pops slot, patches with reply, sets sentinel; on_processing_complete reaps stranded cards via patch (not delete) on FAILURE/CANCELLED paths. Native file attachments via user OAuth: - media.upload hard-rejects bot/SA auth — Google requires user authentication. Same pattern OpenClaw is converging on (openclaw/openclaw#9764). - New gateway/platforms/google_chat_user_oauth.py mirrors the existing google-workspace skill OAuth flow (out-of-band code paste). Token at HERMES_HOME/google_chat_user_token.json. - Scope: chat.messages.create ONLY — least privilege. - /setup-files slash command runs the consent flow IN CHAT, intercepted at adapter level before agent dispatch. - _send_file uses the user-authed Chat client for the two-step media.upload + messages.create-with-attachment. Falls back to a clear text notice (with /setup-files instructions) when no user creds are present or media.upload returns 401/403. Configuration & toolset wiring: - gateway/config.py: Platform.GOOGLE_CHAT enum + env loader for GOOGLE_CHAT_PROJECT_ID / GOOGLE_CHAT_SUBSCRIPTION_NAME / GOOGLE_CHAT_SERVICE_ACCOUNT_JSON. - gateway/run.py: factory + platform-key helpers + auth gating. - hermes_cli/platforms.py: PLATFORMS registry entry. - toolsets.py + tools/send_message_tool.py + cron/scheduler.py: cross-platform tool plumbing. - pyproject.toml: [google_chat] extra (google-cloud-pubsub + google-api-python-client + google-auth + google-auth-httplib2 + google-auth-oauthlib). Documentation: - website/docs/user-guide/messaging/google_chat.md: full setup guide (GCP project, SA, Pub/Sub, OAuth client for /setup-files). - website/docs/reference/environment-variables.md: new env vars. Tests (93 passing): - tests/gateway/test_google_chat.py: enum + env config + helpers + redact + SSRF guard + chunking + Pub/Sub callback routing + send (text / patch / chunked / 403 / 404 / 429) + typing lifecycle + edit_message + delete_message + native attachment delivery (no-creds fallback, two-step upload happy path, 401 fallback, transient HTTP error) + /setup-files slash command + user OAuth helper (load returns None on missing/corrupt token, scopes minimal) + supervisor reconnect + auth email match + cron registry. Refs: - ADDING_A_PLATFORM.md 16-point contract: implemented. - Design doc: ~/.gstack/projects/hermes-agent/ramon-feature-google-chat-gateway-design-20260430-attachments-v2.md
…ound attachments
Three related bugs in the Google Chat adapter, all visible during E2E:
1. Context loss in DMs. Google Chat DMs spawn a NEW thread per top-level
user message — that's a UI artifact, the user views them as one
conversation. But session_key (gateway/session.py:628) includes
thread_id when present, so every fresh top-level message produced a
fresh session_key → agent had no memory of prior turns. ("¿de qué
hemos estado hablando?" → "no veo nada anterior".)
Fix: drop thread_id from SessionSource for chat_type=="dm". Groups
keep thread_id (different threads ARE different conversations there,
matching Telegram forum / Discord thread semantics).
2. Outbound replies landed at top-level instead of staying in the user's
thread. Same root cause: source.thread_id was now None (from fix NousResearch#1),
so base.py passed metadata=None down to send(), and _resolve_thread_id
returned None → no thread on the create body → Chat made a new
top-level message visually disconnected from the user's question.
Fix: cache last-seen inbound thread per chat_id in
_last_inbound_thread; have _resolve_thread_id consult the cache when
metadata.thread_id is empty. Now bot replies follow the user's most
recent thread without re-coupling sessions to threads.
3. Inbound chat-uploaded files (drag-and-drop PDFs / images) were
silently dropped. _download_attachment short-circuited on
source=="DRIVE_FILE", but Google Chat tags BOTH drag-and-drop chat
uploads AND Drive-picker shares with that source string. The
distinction is the presence of attachmentDataRef.resourceName —
present for chat uploads (downloadable via bot SA + media.download),
absent for pure Drive shares (would need user OAuth Drive scope).
Fix: only short-circuit when source=="DRIVE_FILE" AND no
resourceName. Otherwise try the bot media.download path first.
Tests (99 passing): added test_dm_drops_thread_id_from_source_for_session_continuity,
test_group_keeps_thread_id_on_source, TestOutboundThreadRouting (4 cases
covering metadata, cache fallback, override precedence), and
test_drive_file_with_resource_name_uses_bot_path (regression for
the silently-dropped chat-upload case).
When user opens an existing thread and types a message, bot replies were landing at top-level instead of in the user's thread. Root cause: send_typing() created the "Hermes is thinking…" marker WITHOUT a thread field, so Google Chat assigned it the default (top-level / new thread). send() then patches that marker in-place with the agent's reply via messages.patch — but messages.patch CANNOT change a message's thread (it's immutable on update). So even though the eventual create-with-attachment paths resolve the thread correctly, the typing-card patch path was stranding the whole reply outside the user's thread. Fix: send_typing() now resolves the thread the same way send() does (metadata override + last-inbound-thread cache via _resolve_thread_id) and includes it on the create body. The typing card lands in the user's thread; the patched reply stays there. Tests (101 passing): added test_send_typing_inherits_inbound_thread and test_send_typing_no_thread_when_cache_empty.
…onored
Bot replies were landing at top-level instead of inside the user's
thread, even though our code resolved + populated body.thread.name
correctly all the way to the wire. Wire-level traces showed the
Chat API silently dropping our thread.name and creating a brand-new
thread on each create.
Root cause confirmed by Google's own docs:
"Default. Starts a new thread. Using this option ignores any
thread ID or threadKey that's included."
— https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/create
Without passing messageReplyOption, body.thread.name is silently
ignored. The fix: when body carries thread.name, also pass
messageReplyOption="REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" to
.create() — try the requested thread; on failure (e.g. stale name)
fall back to a new thread instead of erroring.
Applied at both message-create call sites:
- gateway/platforms/google_chat.py:_create_message (SA path used by
send_typing, send, send_image, _send_file, /setup-files replies,
fallback notices)
- gateway/platforms/google_chat.py:_send_file (user-OAuth path for
native attachment delivery — separate _user_chat_api.create call)
Tests: 103 passing. Two new pinning tests under TestSend cover both
branches (thread.name present → kwarg added; absent → omitted).
Stripped the ad-hoc TRACE logs added during the wire-level
investigation.
Investigation report:
- Phase 1 instrumented inbound thread parse + send_typing resolve +
_create_message body, deployed, asked user to reproduce.
- Phase 3 traces showed: cache populated correctly, resolved_thread
correct, body.thread.name correct on the wire — but the API
RESULT carried a different thread (a fresh one Google made).
- Cross-referenced Google docs verbatim → confirmed
MESSAGE_REPLY_OPTION_UNSPECIFIED is documented as
"ignores any thread ID".
…g cancellation race Symptom: after the messageReplyOption fix landed, threading worked but turns occasionally left a stuck 'Hermes is thinking…' card in the chat NEXT to the actual reply. Two cards visible per turn: one orphaned, one patched into the response. Root cause: a race between base.py's _keep_typing wrapper and our send_typing implementation. base.py: await asyncio.wait_for(self.send_typing(...), timeout=1.5) When the create-API call took longer than 1.5s (network jitter, OAuth warm-up), wait_for cancelled send_typing mid-flight. But asyncio.to_thread threads CANNOT be cancelled — the underlying Chat API call kept running and successfully created a card in Chat. The cancelled coroutine never reached the line that records the msg_id. Next _keep_typing tick saw an empty slot, called send_typing again, created a SECOND card. Both cards in Chat: one orphan (untracked) + one tracked → patched as the reply. Fix: 1. Reserve the slot with an asyncio.Event in _typing_card_inflight BEFORE starting the API call. Concurrent send_typing calls bail out (or wait on the Event), so a second create never starts even if the first is still in-flight. 2. Run the actual create+record in a background asyncio.Task and await asyncio.shield(task). Cancellation of THIS coroutine no longer cancels the create — the task runs to completion and the msg_id lands in the slot regardless. 3. Defensive: if the background task somehow loses a race with send() (slot already populated when the create finishes), the resulting card is added to _orphan_typing_messages for cleanup. 4. on_processing_complete now reaps orphans by patching them with '·' (a single dot — Chat's text-required minimum) so the user doesn't see stuck 'Hermes is thinking…' messages. Tests (106 passing): added - test_send_typing_concurrent_calls_create_only_one_card (race serialization) - test_send_typing_survives_caller_cancellation (shield pattern verification) - test_orphan_typing_cards_reaped_on_completion (cleanup invariant) Investigation method: the Phase 1 wire-trace from the previous bug (messageReplyOption) confirmed body.thread.name was correct. The remaining symptom (orphan card) had to be a parallel-create race — the only path that lets two cards exist for one turn. Code reading of base.py:_keep_typing's wait_for(timeout=1.5) confirmed the cancellation window, and Python's asyncio docs confirmed to_thread does not cancel underlying threads.
…ssion Symptom (regression after the messageReplyOption thread fix): user opens an existing thread, says 'Hola\!', asks 'dime los mensajes anteriores' — bot replies with messages from OTHER threads. All DM threads were sharing one session. Background: my earlier fix for "DMs lose context across messages" (Bug NousResearch#1) dropped thread_id from the source for ALL DM messages, on the theory that Google Chat creates a fresh thread per top-level user message and those auto-threads aren't real conversational containers. That was correct for top-level main-flow messages but wrong for explicit thread re-engagements. Root cause analysis: Google Chat doesn't expose a flag distinguishing "new top-level message (Chat auto-creates thread)" from "user clicked Reply in thread on existing message". Both produce the same envelope shape with thread.name set. Heuristic fix: count inbound messages per (chat_id, thread). The PRE-increment value is the signal: count == 0 → first time we see this thread → main-flow, share session by chat_id only. count >= 1 → we've seen this thread before → user re-engaged it explicitly → isolate session by chat_id+thread_id. The intuition: Chat auto-threads always start with exactly one message (the one being processed). Threads that have prior messages were touched in a previous turn, so the user's act of writing in them now is a deliberate re-engagement. Limitation: doesn't survive gateway restart (in-memory counts). Acceptable for v1 — restart wipes session memory anyway. Persistence can come later if needed. Tests (108 passing): added - test_dm_first_message_in_thread_is_main_flow - test_dm_second_message_in_same_thread_is_side_thread (regression pin for Ramón's report) - test_dm_different_top_level_threads_share_session Group spaces unchanged: threads always isolate session (Telegram forum / Discord thread parity).
… message
Symptom: when user typed in the DM input box (top-level main flow),
Chat displayed the bot's reply as an expandable thread under the
user's message ("Replies (1)" UI) instead of as an adjacent
top-level card. The conversation looked threaded even though it
should look like normal back-and-forth chat.
Root cause: the prior thread-isolation heuristic correctly identified
main-flow vs side-thread for SESSION purposes (drop thread_id from
source for main flow), but the OUTBOUND thread cache
(_last_inbound_thread) was still populated for every inbound. When
send_typing / send / _send_file resolved the outbound thread via
_resolve_thread_id, they got the user's auto-created thread name and
posted the bot reply with thread.name set. With messageReplyOption=
FALLBACK_TO_NEW_THREAD honored (correct from earlier fix), the reply
landed in the user's thread → expanded-thread UX.
Fix: only populate _last_inbound_thread for SIDE threads (where the
session is also isolated). For main-flow messages, clear the cache.
Resolve_thread_id then returns None → bot reply has no thread.name →
Chat creates a fresh top-level thread for the reply → adjacent
top-level cards.
For groups, behavior unchanged: always populate cache, always thread.
Tests (110 passing):
- Updated test_dm_first_message_in_thread_is_main_flow to assert
cache stays empty (regression pin).
- Added test_dm_side_thread_caches_thread_for_outbound (positive
cache for side threads).
- Added test_dm_main_flow_after_side_thread_clears_cache (transition
from side to main flow clears stale cache).
…d detection survives gateway restart
After 4 iterations of in-memory thread heuristics, the underlying bug
was that the counts dict was wiped on every gateway restart. Once
wiped, every active side thread silently demoted back to "main flow",
which leaked main-flow context into what the user perceived as an
isolated thread. Each "fix" addressed one scenario by rebalancing the
heuristic and broke another.
Root cause (from /office-hours v3 design doc):
"v3 was correct in principle but counts lived in memory. Every
gateway restart wiped them, so 'we've seen this thread before' became
uncertain, and the heuristic flipped main-flow ↔ side-thread between
restarts."
Fix: a tiny persistent store at
${HERMES_HOME}/google_chat_thread_counts.json. Counts survive restart;
side-thread classification stays consistent across the lifecycle of a
chat.
Implementation:
- New _ThreadCountStore class (gateway/platforms/google_chat.py) with
load() / get() / incr() / _save(). Atomic write via tmpfile + rename.
- Failure modes are non-fatal: missing file, corrupt JSON, OSError on
save → log warn, continue with in-memory state.
- Schema-validation on load drops malformed entries (defends against
hand-edited files).
- Wired into _initialize() (load) and _build_message_event() (incr).
- Test fixture redirects the store to a tmp_path so tests don't touch
~/.hermes/.
Tests (116 passing, +6 new):
- test_missing_file_returns_zero_counts
- test_corrupt_json_treated_as_empty
- test_incr_returns_pre_increment_value
- test_round_trip_persists_across_load
- test_invalid_shape_dropped_silently
- test_side_thread_detection_survives_restart ← regression pin for
the multi-iteration bug, simulates gateway restart with fresh
adapter instance pointing at the same persistence file.
Pruning is deliberately deferred — at scale a multi-active-chat bot
might want LRU caps, but for single-user self-host the file stays
under ~10 KB indefinitely.
Closes the threading saga that started with commit e4000354
("isolate user-opened threads"). The model has been right since v3;
it just needed durable state.
… store The persistent-counts fix landed but live testing on mac-mini showed the symptom returning under one specific flow: user clicks 'Reply in thread' on the BOT's prior message. Inspection of the on-disk store revealed every active thread sat at count==1 (only the user's first reply), never reaching the side-thread threshold. Root cause: I was incrementing only on INBOUND in _build_message_event. The bot's own reply lands in some thread.name (returned by Chat API), and when the bot replies main-flow without thread.name set, Chat creates a fresh thread for it. That fresh thread was invisible to the count store. A user 'Reply in thread' on the bot's message arrived with thread.name = the bot's auto-created thread, which the store had never seen → prev_count = 0 → main flow → bot replies top-level. Symptom: user types in a thread, bot answers outside it. Fix: after every successful _create_message call, extract thread.name from the API response and increment the count for the destination thread. Same fix applied to the user-OAuth attachment path (_send_file's separate _user_chat_api.create call). Both outbound paths now register their destinations so subsequent user replies in those threads are correctly classified as side-thread. Tests (117 passing, +1 new): - test_outbound_thread_tracked_for_user_reply_in_bot_thread Regression pin: bot creates thread T_BOT via outbound, then user replies in T_BOT → must be classified as side thread (event.source. thread_id set, _last_inbound_thread populated). Discovery method: queried the live persistence file (~/.hermes/google_chat_thread_counts.json) on mac-mini after Ramón reproduced the bug. Saw all counts == 1 instead of the expected >=2 for active side threads → realized only inbound was tracked. Iron Law of /investigate respected: no fix proposed without evidence from the wire (the persistence file). Single change to the inbound tracking would have been wrong; the real fix was to track both directions of the conversation.
Previously the bot held a single user-OAuth token and tried to use it regardless of who asked for a file. When User B asked for a file in B's DM, the bot still uploaded as Ramón — and messages.create returned 403 because Ramón is not a member of B's DM space. Now each user authorizes once in their own DM via /setup-files. The bot stores per-user refresh tokens at ~/.hermes/google_chat_user_tokens/<sanitized_email>.json and looks up the asker's token on each outbound attachment via _last_sender_by_chat (populated from inbound sender.email). Backward compatible: the legacy ~/.hermes/google_chat_user_token.json stays as a fallback when the asker has no per-user token yet, so pre-multi-user installs keep working without forced re-setup. Slash command /setup-files now operates on the sender's identity: status, start, exchange, and revoke all scope to that user. A 401/403 from one user's token evicts only that user's slot — never another user's nor the legacy fallback. Tests: 13 new (per-email storage, sanitization, list_authorized_emails, per-user lookup, scoped 401 eviction, slash command per-sender writes, scoped revoke). All 117 prior google_chat tests still pass — total 130. Docs: new Step 10 in the user guide (why the flow exists, host setup, per-user authorization, scope, multi-user behavior) plus three new troubleshooting entries and a security-notes bullet on token storage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream v0.12.0 introduced a platform plugin host (28 commits, salvage of NousResearch#17664) and shipped Microsoft Teams + IRC as the first plugin-based adapters. The published guidance in gateway/platforms/ADDING_A_PLATFORM.md and the developer-guide doc is explicit: plugins are recommended for community/third-party adapters, built-in is reserved for core contributors. This PR is community. Migration: - New layout under plugins/platforms/google_chat/: - plugin.yaml (manifest, requires_env) - __init__.py (re-exports register) - adapter.py (renamed from gateway/platforms/google_chat.py) - oauth.py (renamed from gateway/platforms/google_chat_user_oauth.py) - Internal cross-import google_chat_user_oauth → relative .oauth. - sys.path.insert hack at the top of the adapter removed; the plugin loader handles import paths. - register(ctx) entry point added: registers the platform with check_fn, validate_config, is_connected, required_env, install_hint, allowed- users env, max_message_length, emoji, platform_hint. - check_fn used by the registry pass also requires GOOGLE_CHAT_PROJECT_ID and _SUBSCRIPTION_NAME so the platform doesn't appear in cfg.platforms when the user hasn't configured Pub/Sub yet (preserves the legacy user-facing semantic from the in-tree gate). - gateway/run.py: removed the Platform.GOOGLE_CHAT arm in _create_adapter — the platform_registry lookup at the top of the method now drives dispatch. - tests/gateway/test_google_chat.py: updated imports to point at the plugin module paths (gateway.platforms.google_chat -> plugins.platforms.google_chat.adapter, _user_oauth -> .oauth). Backward compatibility: - Platform.GOOGLE_CHAT enum entry kept in gateway/config.py so existing config files and downstream dispatch tables work unchanged. - Per-user OAuth tokens at ${HERMES_HOME}/google_chat_user_tokens/ and the legacy ${HERMES_HOME}/google_chat_user_token.json paths are unchanged. No re-authorization required for the production tokens already on mac-mini. - The pip extra [google_chat] is unchanged. Tests: 130 google_chat tests still pass. Full gateway suite shows no new regressions (the 11 pre-existing dingtalk/whatsapp failures are missing-SDK environmental, unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_chat The plugin loader at hermes_cli/plugins.py:_load_directory_module imports bundled plugins under the synthetic namespace ``hermes_plugins.platforms__google_chat`` so they don't collide with in-tree modules. ``__name__`` inside the adapter resolves to that synthetic name — silently breaking every operator log filter, log-tail grep, and stderr_handler match that uses the legacy ``gateway.platforms.google_chat`` prefix. Restoring the pre-migration log line format keeps the on-disk ``gateway.log`` shape identical so the existing ``grep '[GoogleChat]'`` muscle memory keeps working and downstream log monitors don't have to relearn the prefix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-checks against the Teams plugin PR (NousResearch#17828) surfaced four config surfaces this PR was missing. Closes those gaps so the diff matches the canonical bundled-plugin shape upstream maintainers expect. - ``.env.example``: new GOOGLE_CHAT_* block (project, subscription, SA JSON, allowed-users, home channel) with a 4-step setup pointer to the user-guide doc. - ``cli-config.yaml.example``: ``google_chat`` added to the supported platform key list, default toolset entry ``google_chat: [hermes-google-chat]``. - ``docker-compose.yml``: GOOGLE_CHAT_* env vars exposed to the gateway service (commented by default, with a note about mounting the SA JSON via volumes). - ``gateway/config.py``: ``_PLATFORM_CONNECTED_CHECKERS`` entry kept and documented as redundant with the plugin's ``_is_connected`` callback but required to satisfy ``test_platform_connected_checkers`` (the enum entry stays to avoid a ~15-call-site rewrite of ``Platform.GOOGLE_CHAT`` to ``Platform("google_chat")``). - ``tests/gateway/test_google_chat.py``: docstring on the import block explaining why we don't use ``load_plugin_adapter`` like Teams does (our plugin has a companion ``oauth.py`` that uses relative imports; the helper doesn't set ``__package__``, so relative imports inside the loaded adapter fail). The fully-qualified package import is the shape the conftest anti-pattern guard accepts. Tests: 130/130 google_chat green. Full ``tests/gateway/`` shows the same pre-existing dingtalk/whatsapp missing-SDK failures unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gin pattern Audit against Teams' PR NousResearch#17828 found we were touching ~12 core files that Teams doesn't, almost all because google_chat originally landed as an in-tree adapter and we kept those touches when migrating to a plugin. This removes everything redundant with the plugin/registry path so the diff matches the canonical bundled-plugin shape. Removed (redundant — registry / generic plugin path covers it): - ``agent/prompt_builder.py`` — PLATFORM_HINTS["google_chat"] entry; the plugin's ``register_platform(platform_hint=…)`` already feeds ``run_agent.py:_build_system_prompt`` for plugin platforms. Hint content folded into the registered platform_hint (and absorbs the session_context guardrails too). - ``gateway/run.py`` — five dispatch table entries (``_builtin_allowed_users_vars``, ``_builtin_allow_all_vars``, ``platform_user_env_map``, ``platform_allow_all_map``, the second ``platform_env_map``); the registry-aware fallback at line ~3962 picks up the plugin's ``allowed_users_env`` / ``allow_all_env``. Also removed the leftover ``elif platform == Platform.GOOGLE_CHAT:`` comment tombstone. - ``tools/send_message_tool.py`` — ``_send_google_chat`` (~50 LOC) and the ``Platform.GOOGLE_CHAT`` branch in ``_send_to_platform``; the generic ``_send_via_adapter`` fallback dispatches through the live plugin adapter. - ``gateway/session.py`` — ``elif Platform.GOOGLE_CHAT:`` block in ``build_session_context_prompt``; the platform_hint subsumes those behavioral guardrails. - ``toolsets.py`` — ``"hermes-google-chat"`` entry and its inclusion in ``hermes-gateway``; ``resolve_toolset`` auto-generates a toolset for any plugin platform via the registry (and the hyphenated form was unreachable anyway — plugin name is ``google_chat``). - ``hermes_cli/platforms.py`` / ``hermes_cli/status.py`` — static PLATFORMS / status dict entries; Teams adds neither. - ``hermes_cli/gateway.py`` — ``_PLATFORMS`` setup wizard data block (~33 LOC) moved into the plugin as ``interactive_setup()`` registered via ``setup_fn`` — same pattern Teams uses. - ``agent/redact.py``, ``gateway/channel_directory.py``, ``tools/cronjob_tools.py``, ``AGENTS.md``, ``README.md`` — generic improvements / project listing touches reverted to keep PR scope tight; can resubmit as separate cleanup PRs if motivated. cli-config.yaml.example: toolset name corrected from ``hermes-google-chat`` (hyphen, never resolved) to ``hermes-google_chat`` (underscore — matches the ``f"hermes-{platform}"`` fallback that triggers resolve_toolset's plugin auto-generation). Kept (genuinely platform-specific): - ``gateway/config.py`` — Platform enum entry + populator block + the documented redundant ``_PLATFORM_CONNECTED_CHECKERS`` entry. Removing the enum would touch ~15 call sites; the populator wires GOOGLE_CHAT_HOME_CHANNEL into config.platforms which the cron scheduler reads; the checker satisfies ``test_platform_connected_checkers``. - ``gateway/run.py`` — the ``if source.platform == Platform.GOOGLE_CHAT and source.user_id_alt:`` block. Operators configure GOOGLE_CHAT_ALLOWED_USERS with email addresses, but ``source.user_id`` is ``users/{id}`` (Chat resource name); the bridge merges ``user_id_alt`` (email) into the allowlist match set so email-based configuration works. - ``cron/scheduler.py`` — ``_KNOWN_DELIVERY_PLATFORMS`` / ``_HOME_TARGET_ENV_VARS`` entries. Teams' TEAMS_HOME_CHANNEL is documented but not actually wired (Teams left this gap); we keep google_chat home-channel cron delivery working until upstream lands generic plugin home_channel support. Diff went from 27 changed files to 15. Plugin side gained ``interactive_setup()`` + a beefier ``platform_hint`` to absorb the prompt_builder + session_context content. 130/130 google_chat tests green; ``test_platform_connected_checkers`` and ``test_prompt_builder`` both pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
053989c to
c656edc
Compare
|
Thanks @alt-glitch — I should have searched harder before opening this. @ArnarValur's #14965 has been open since Apr 24 and I missed it. I'd rather not waste maintainer time reviewing two competing PRs, so let me lay out the differences objectively and defer to whatever path the maintainers (and Arnar) prefer. Architecture
Worth noting: the plugin host in v0.12.0 (PR #17828, Apr 30) landed AFTER #14965 was opened. Both PRs are doing the right thing for the upstream conventions that existed when they started. Feature scope
The native-attachment gap is the load-bearing one — Google Chat's Consolidation paths I'd be happy with
Pinging @ArnarValur — would love to coordinate on whichever path makes sense to you and the maintainers. |
The Chat API publishes events with one of three envelope shapes
depending on how the bot is configured:
1. Workspace Add-ons (canonical, ce-type-driven):
{"chat": {"messagePayload": {"message": {...}, "space": {...}}}}
2. Native Chat API Pub/Sub (bot configured without the Workspace
Add-ons wrapper — events arrive directly from the publisher):
{"type": "MESSAGE", "message": {...}, "space": {...}}
3. Relay / flat (custom Cloud Run relay that flattens Chat events into
top-level fields so the bot can run without GCP credentials):
{"event_type": "MESSAGE", "sender_email": ..., "text": ...,
"space_name": ..., "thread_name": ..., "message_name": ...}
Previously only format 1 was parsed — format 2 and 3 fell through to
the messagePayload-missing branch and dropped silently. _on_pubsub_message
now delegates to a new _extract_message_payload helper that returns
(message, space, format_name) for any of the three, synthesizing a
Chat-API-shaped message dict for format 3 so downstream code
(_dispatch_message → _build_message_event) reads all three identically.
The relay-format synthesis includes a deterministic ``users/relay-…``
sender name surrogate so dedup keys stay stable across at-least-once
redelivery (Pub/Sub may replay the same logical event).
Tests: 4 new under TestExtractMessagePayload — native format extraction,
non-MESSAGE filtering, relay synthesis assertions, unrecognized
envelope handling. 130 prior tests still pass (134 total).
Patterns adapted from PR NousResearch#17828's NousResearch#14965 baseline by @ArnarValur,
who maintained a Workspace install for several weeks and identified the
parser coverage gap.
Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stry Three reliability/operability improvements pulled from PR NousResearch#14965: 1. Outbound retry with exponential backoff. Wraps `_create_message`'s `messages.create().execute()` in `_call_with_retry`, a reusable helper that retries 3x on 429/5xx/timeout/connection errors with 1s → 8s base + 30% jitter. Permanent errors (4xx other than 429, auth, programmer errors) bubble up on the first attempt — no point masking misconfiguration with retries. Without this wrapper, a single 503 from Google's Chat REST API drops the user-visible reply silently. 2. Application Default Credentials fallback. `_load_sa_credentials` now tries `google.auth.default()` when no SA JSON is configured, so deploys on Cloud Run / GCE / GKE with workload identity work without managing key files. Local users keep using the explicit SA JSON path; only the unconfigured case changes (used to error, now ADCs). 3. Env-var registry entries for `GOOGLE_CHAT_PROJECT_ID`, `_SUBSCRIPTION_NAME`, `_SERVICE_ACCOUNT_JSON`, `_ALLOWED_USERS`, `_HOME_CHANNEL` in `hermes_cli/config.py`. Makes the env vars discoverable in `hermes config` UI under the "messaging" category, matching how Slack/Telegram/Mattermost are exposed. Tests: 6 new (3 retry, 1 retryable-classifier, 2 ADC). 134 prior tests still pass (140 total). Patterns adapted from PR NousResearch#17828's NousResearch#14965 baseline by @ArnarValur. Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…strip
Two outbound polish improvements that fire on every reply:
1. `format_message`: convert standard Markdown emitted by the LLM to
Google Chat's formatting dialect before the message hits the wire.
Without this, **bold**, [text](url), and # headers render as
literal asterisks/brackets/hashes — Chat's renderer ignores them.
Conversions:
**bold** → *bold* (Chat's bold uses single asterisks)
***x*** → *_x_* (compound bold-italic)
[txt](url) → <url|txt> (Slack-style anglebracket links)
# Header → *Header* (Chat has no header support)
Code blocks (fenced AND inline) are protected via placeholder
substitution so backtick-wrapped content with literal asterisks or
brackets stays verbatim — matters because the LLM regularly emits
Python (`x = 2 ** 10`) and shell snippets that would otherwise get
mangled.
2. Invisible Unicode strip: ZWJ, ZWNJ, ZWS, BOM, LTR/RTL marks, and
Variation Selectors get removed before send. These render as tofu
(□) in Chat's restricted font stack, especially in composite emoji
like 👨👩👧 (family) and ZWJ flag sequences. Stripping leaves the
base codepoints intact, so the user sees the closest-renderable
form instead of square boxes.
Hooked into `send()` BEFORE chunking so the 4000-char limit applies
to the rendered form, not the source markdown. `send_image_file` and
other paths that build their own message bodies are NOT yet covered;
they emit pre-formatted text from internal callers and don't need the
LLM-output normalization. Can extend later if needed.
Tests: 12 new under TestFormatMessage covering all conversions, code
block protection (fenced + inline), edge cases (URLs with parens,
unmatched syntax, mid-line hashes, mixed bold+italic), Unicode
stripping (ZWJ in composite emoji, BOM, bidi marks), empty/None
safety. Two assertions document KNOWN limitations: URLs with parens
truncate at the first `)`, and pure-whitespace input collapses to one
space. 140 prior tests still pass (152 total).
Pattern lifted from PR NousResearch#14965 by @ArnarValur.
Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Identity convention swap to match Teams' canonical pattern, eliminating
one of our 4 core touches in the process:
Before:
source.user_id = "users/{id}" (Chat resource name)
source.user_id_alt = sender_email
+ a google_chat-specific block in gateway/run.py:_is_user_authorized
that injected user_id_alt into the allowlist match set so
GOOGLE_CHAT_ALLOWED_USERS=email@x.com would match
After:
source.user_id = sender_email or sender_name (email when present)
source.user_id_alt = sender_name (the "users/{id}" resource name)
+ the bridge block in gateway/run.py is REMOVED — the generic
allowlist match in _is_user_authorized now finds the email
naturally because it IS the canonical user_id
Why:
- Operators configure GOOGLE_CHAT_ALLOWED_USERS with email addresses
(the value Google Chat surfaces in its UI), so the email is the
natural canonical id. Less indirection for operators.
- Removes one of our 4 core touches in gateway/run.py — net diff
reduction in the upstream PR.
- Matches Teams' identity convention (Teams uses the AAD object ID
directly as user_id without a side channel).
- Falls back to the resource name when sender has no email (rare —
bot-to-bot or system events) so allowlists keyed by users/{id}
still work for that path.
Migration risk: theoretical only. Existing group sessions that hashed
user_id from the resource name would get new session keys after the
swap. In production: zero google_chat group sessions exist (only DMs,
which key off chat_id). The PR has not shipped, so no upstream
operator has stable group-session state to lose.
Tests: 1 updated (TestBuildMessageEvent: user_id == email,
user_id_alt == "users/12345"), 1 renamed + 1 added in
TestAuthorizationEmailMatch (canonical email-match case + fallback
to resource name when no email is available). 152 prior tests still
pass (153 total).
Pattern lifted from PR NousResearch#14965 by @ArnarValur.
Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Great work! Really hoping this makes it in soon. Much needed as Google Chat has a massive user base. |
|
+1 here, I'm excited about this. I was unaware of the file attachment delivery issues. Thanks @donramon77 for catching those. |
|
+1 here, I'm excited about this |
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR #18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR #18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
|
Merged via PR #21306. Your Google Chat adapter, OAuth helper, tests, and docs were cherry-picked with authorship preserved via rebase-merge — commit 44cd79e on main keeps your name in Small rewire during salvage: the adapter now uses two new generic plugin hooks ( The generic hooks you motivated also retrofit IRC and Teams. Teams now has working cron delivery (long-documented but never wired) and Thanks for a well-scoped, well-tested contribution — 153 Google Chat tests all green, 2 weeks of production validation, and a clean PR body made this one easy to salvage. 🙌 |
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
* fix(auth): shorten credential 401 cooldown
* fix: add dashboard to CLI help epilogue and Docker CI smoke test
- Add hermes dashboard examples to the CLI help epilogue so users can
discover the web UI command from 'hermes --help' output
- Add an independent 'Test dashboard subcommand' CI step that verifies
'hermes dashboard --help' works in the Docker image, with its own
mkdir/chown setup to remain independent of the prior smoke test step
- Prevents regressions like #9153 where the dashboard subcommand was
present in source but missing from the published Docker image
Closes #9153
* fix(docker): chown runtime node_modules trees to hermes user (#18800)
* feat(kanban): convert inline-create title input to multiline textarea
- Changed Input component to native textarea for task creation
- Removed Enter-to-submit behavior (use Create button instead)
- Added proper styling: border, padding, rounded corners, focus ring
- 2-row default height with vertical resize and max-height cap
- Escape still cancels the form
* fix(kanban): restore Enter=submit, Shift+Enter=newline in inline-create textarea
The textarea conversion in the previous commit dropped Enter-to-submit
entirely, requiring a mouse click on Create for every single-line task.
Restore the common-case shortcut while preserving multiline entry:
- Enter (no modifier) submits the form
- Shift+Enter inserts a newline
- Escape still cancels
Matches the convention used by Slack, Discord, GitHub PR comment boxes.
* fix(vision): Z.AI vision model compatibility — endpoint routing and max_tokens handling
Z.AI (智谱 GLM) vision models (glm-4v-flash, glm-4v-plus, etc.) have two
compatibility issues when used through the Anthropic-compatible endpoint:
1. **Error 1210 — max_tokens rejected on multimodal calls**: Z.AI rejects
the max_tokens parameter for vision model requests with error code 1210
("API 调用参数有误"). The error string does not contain "max_tokens",
so the existing unsupported-parameter retry logic never fires.
2. **Wrong endpoint inheritance**: When the main runtime provider uses Z.AI's
Anthropic-compatible endpoint (open.bigmodel.cn/api/anthropic), the vision
client inherits this endpoint. But Z.AI's Anthropic wire cannot properly
handle image content — models silently fail ("I can't see the image") or
reject max_tokens.
Changes:
- resolve_vision_provider_client(): force Z.AI vision to use OpenAI-compatible
endpoint (open.bigmodel.cn/api/paas/v4) instead of inheriting Anthropic wire
- _build_call_kwargs(): skip max_tokens for Z.AI vision models (4v/5v/-v suffix)
- _AnthropicCompletionsAdapter: support _skip_zai_max_tokens flag
- _to_openai_base_url(): rewrite Z.AI Anthropic URLs to OpenAI-compatible path
- call_llm() retry: detect Z.AI error 1210 and strip max_tokens before retry
* chore: AUTHOR_MAP entry for @agilejava
* fix(auxiliary): enforce Codex Responses stream timeout
## Summary
- Forwards chat-completions `timeout` into the Codex Responses stream call.
- Adds total elapsed-time enforcement while the Responses stream is still yielding events.
- Closes the underlying client on timeout to unblock stalled streams, then raises `TimeoutError`.
- Adds focused tests for timeout forwarding and total timeout enforcement.
## Why
The Codex auxiliary adapter can be used by non-interactive auxiliary work such as context compression. If the stream keeps yielding progress-like events but never completes, SDK socket/read timeouts do not necessarily protect the full operation. This makes the CLI look stuck until the user force-interrupts the whole session.
This is a refreshed upstream-ready version of the earlier fork fix around `d3f08e9a0` / PR #3.
## Verification
- `python -m py_compile agent/auxiliary_client.py tests/agent/test_auxiliary_client.py`
- `python -m pytest -o addopts='' tests/agent/test_auxiliary_client.py::TestCodexAuxiliaryAdapterTimeout -q`
- `git diff --check`
* chore: AUTHOR_MAP entry for @acc001k
* fix(security): require explicit allowlist or TEAMS_ALLOW_ALL_USERS opt-in for Teams approval buttons
* feat(image-gen): honor image_gen.model from config.yaml in plugin dispatch
Image generation plugins were dispatched without a model name, leaving
the plugin to pick its default. Users on OpenRouter, ComfyUI, or custom
backends had no way to select a specific model through config — they
had to fork the plugin or patch the tool.
Add _read_configured_image_model() that reads image_gen.model from the
active profile's config.yaml and forwards it into
_dispatch_to_plugin_provider(). When model is set, the plugin call
gains a 'model' kwarg; when unset, the plugin falls back to its own
default, so single-model users see no behavior change.
Example config:
image_gen:
provider: openrouter
model: flux-pro
Tests: all 170 image tool tests pass. The new code path is opt-in via
config and no existing test exercises it, so the change is strictly
additive.
* chore: AUTHOR_MAP entry for @kowenhaoai
* fix(gateway): log agent task failures instead of silently losing usage data
* chore: AUTHOR_MAP entry for wabrent
* fix(mcp): clear stale thread interrupt before MCP discovery
Fixes #9930
When an agent session is interrupted (Ctrl+C or gateway timeout), the
current thread's interrupt flag is set in _interrupted_threads. asyncio
executor threads are pooled and reused across sessions, so a thread that
carried an interrupt flag from a prior session will immediately cancel
any new asyncio work dispatched to it — including MCP server discovery.
Fix: in register_mcp_servers(), temporarily clear the interrupt flag on
the current thread before running _discover_all(), then restore it
afterward in a finally block so the original interrupt state is not lost.
* chore(release): add AJV20 to AUTHOR_MAP for PR #10287 salvage
* fix(security): support SRI integrity verification for dashboard plugin scripts
* fix(gateway): surface bootstrap failures to stderr instead of silently swallowing
* fix(gateway): include exception detail in bootstrap warning output
Follow-up to the salvaged warning. Without the exception string,
operators see "config validation failed" with no hint why.
* Fix WhatsApp long message splitting
* chore: AUTHOR_MAP entry for @hedirman
* fix(mcp): report configured timeout in MCP call errors
Track elapsed wall time in _run_on_mcp_loop, cancel the in-flight future when a timeout expires, and raise a descriptive TimeoutError that includes the elapsed and configured timeout. Add regression coverage for the new timeout diagnostics.
* chore(release): add masonjames to AUTHOR_MAP for PR #10439 salvage
* fix: use configured model for gateway auth fallback
* chore: AUTHOR_MAP entry for @LucianoSP
* fix(gateway): log platform status write failures instead of silently swallowing
* fix(gateway): consolidate runtime-status writes + rate-limit failure logs
Extracts the three try/write_runtime_status/except-log blocks into a
shared _write_runtime_status_safe() helper. On failure, logs the first
occurrence per (platform, context) at warning level and downgrades
subsequent failures to debug — so a persistently broken status dir
(permissions, ENOSPC) doesn't spam the log on every Telegram reconnect.
Uses getattr for the _status_write_logged set so test harnesses that
skip __init__ (object.__new__(Adapter)) don't break.
Follow-up to the salvaged #21158.
* fix(gateway): honor configured goal turn budget
* chore: AUTHOR_MAP entry for @paul-tian
* fix(models): add alibaba-coding-plan to _PROVIDER_MODELS curated list
The alibaba-coding-plan provider (DashScope coding-intl endpoint) was
defined in providers.py but missing from _PROVIDER_MODELS in models.py.
This caused /model to show "0 models" for this provider even though
credentials were configured and the provider was functional.
Add the curated model list so the provider picker displays available
models correctly.
* fix(mcp): retry stale pipe transport failures
Treat closed-resource, closed-transport, broken-pipe, and EOF MCP failures as stale session equivalents so the existing reconnect/retry-once path can recover. Add regression coverage for the stale-pipe marker variants.\n\nChecks:\n- python -m py_compile tools/mcp_tool.py tests/tools/test_mcp_tool_session_expired.py\n- python -m pytest tests/tools/test_mcp_tool_session_expired.py -q -o addopts=\n- selected secret scan over touched files
* chore(release): add subtract0 to AUTHOR_MAP for PR #19935 salvage
* fix(mcp): include exception type in error messages when str(exc) is empty
Some exception classes (e.g. anyio.ClosedResourceError) are raised without
a message argument, so str(exc) returns an empty string. The existing error
format f'{type(exc).__name__}: {exc}' would produce messages like
'MCP call failed: ClosedResourceError: ' with nothing after the colon.
Add _exc_str() helper that falls back to repr(exc) when str(exc) is empty,
and apply it to all 6 MCP error formatting sites (5 tool/prompt/resource
handlers + 1 sampling handler).
Fixes #19417
* feat(models): add paid tencent/hy3-preview route on OpenRouter (#21077)
Add tencent/hy3-preview (without :free suffix) as a paid model route
alongside the existing free variant. This allows seamless transition
when the model moves from free to paid on OpenRouter — both routes
coexist so neither side's timing causes breakage.
Changes:
- models.py: add ("tencent/hy3-preview", "") to OPENROUTER_MODELS
- model-catalog.json: add paid variant entry
- tests: add assertions for paid route presence
The :free entry can be removed in a follow-up PR once OpenRouter
confirms the free route is deprecated.
Co-authored-by: simonweng <simonweng@tencent.com>
* fix(cli): replace get_event_loop() with get_running_loop() to silence RuntimeWarning in process_loop thread (#19285)
* chore: correct AUTHOR_MAP for oluwadareab12 (was mismapped to bennytimz)
* fix(tui): render structured content on resume
* chore: AUTHOR_MAP entry for @glesperance
* feat(dashboard): support serving under URL prefix via X-Forwarded-Prefix
The Hermes dashboard previously assumed it was served at the root of its
host (e.g. https://kanban.tilos.com/). When mounted behind a path-prefix
reverse proxy (e.g. https://mission-control.tilos.com/hermes/), the SPA
404'd because:
- index.html shipped absolute /assets/index-*.js URLs
- React Router had no basename
- The plugin loader hit /dashboard-plugins/<name>/... at the root host
- CSS in the bundle had absolute url(/fonts/...) references
This patch makes the dashboard prefix-aware at runtime, no rebuild
required. The proxy injects 'X-Forwarded-Prefix: /hermes' on every
request and the Python server:
- Rewrites href/src in served index.html to '${prefix}/assets/...'
- Injects 'window.__HERMES_BASE_PATH__="${prefix}"' for the SPA to read
- Rewrites url() refs in CSS at serve time
The SPA reads window.__HERMES_BASE_PATH__ once at boot and:
- Prefixes all /api/... fetches via api.ts
- Prefixes all /dashboard-plugins/... script/css URLs in usePlugins
- Sets <BrowserRouter basename={...}> so client-side routing works
When no X-Forwarded-Prefix header is present, behavior is unchanged
(empty prefix => serves at root, kanban.tilos.com keeps working).
Refs: MC-AUTO-13
* fix(agent): honor configured model max tokens
* fix(delegate): expand composite toolsets before intersection in delegate_task
When the parent agent uses a composite toolset like hermes-cli, calling
delegate_task with individual toolsets (e.g. web, terminal) resulted in
zero tools because the name-based intersection failed: 'web' != 'hermes-cli'.
Add _expand_parent_toolsets() which collects all tool names from parent
toolsets, then recognises any individual toolset whose tools are a subset
of the parent's available tools. This allows delegate_task(toolsets=['web'])
to work correctly when the parent has hermes-cli enabled.
Fixes #19447
* fix(compressor): soften summary prompt for content filters
* fix(kanban): make code/pre styling theme-immune across all themes (#21086) (#21247)
The original #21086 report was theme-accent opaque fills behind JSON
payload values in the Kanban Task Drawer's EVENTS section. The first
iteration of this fix was narrow — add ``!important`` to the specific
drawer/payload overrides. But "all themes" includes user-installable
themes we haven't written yet, and any theme doing the normal
``code { background: ... !important }`` dance would break this again.
Replace the whack-a-mole approach with a structural reset:
1. Inside ``.hermes-kanban`` (and the ``.hermes-kanban-drawer`` portal
container), reset EVERY ``<code>`` and ``<pre>`` to transparent
with ``!important``. This is the new default.
2. Opt back in ONLY on the classes that carry intentional pill
styling:
- ``.hermes-kanban .hermes-kanban-md code`` (inline code in task
Markdown body) — ``:not()`` scoped to exclude fenced blocks.
- ``.hermes-kanban pre.hermes-kanban-md-code`` (fenced block
wrapper) — higher specificity than the reset so it wins cleanly.
Net effect: any theme — shipped or third-party — can ship whatever
global ``code``/``pre`` rule it wants; kanban surfaces stay clean
unless the theme deliberately targets our internal class names, which
would be a conscious override rather than an accidental breakage.
Verified live against a hostile synthetic theme that paints
``code``, ``pre``, AND ``.hermes-kanban code`` / ``.hermes-kanban pre``
with ``background: !important`` fills. Every kanban surface stayed
correct (transparent where expected, intentional pill fill where
expected). Also verified across all 7 shipped themes by pointing a
headless browser at a live dashboard.
| Surface | Expected | Got |
|----------------------------------------------------|--------------------|-------------------|
| Outside ``.hermes-kanban`` (sanity) | hostile fill | hostile fill ✓ |
| Drawer ``.hermes-kanban-event-payload`` (the bug) | transparent | transparent ✓ |
| Drawer bare ``<code>`` | transparent | transparent ✓ |
| Drawer bare ``<pre>`` | transparent | transparent ✓ |
| Markdown inline ``<code>`` | subtle pill | subtle pill ✓ |
| Markdown fenced block ``.hermes-kanban-md-code`` | subtle pill | subtle pill ✓ |
| Markdown fenced inner ``<code>`` | transparent | transparent ✓ |
Closes #21086.
* fix(whatsapp): reject strangers by default, never respond in self-chat (#8389) (#21291)
Self-chat mode (default) previously replied to ANY incoming DM with a
Python-side pairing-code message. Two compounding defaults:
1. allowlist.js::matchesAllowedUser returned true for an empty
allowlist — so WHATSAPP_ALLOWED_USERS unset → everyone passes the JS
bridge gate → messages reach Python gateway → _is_user_authorized
returns False but _get_unauthorized_dm_behavior falls back to
'pair' → stranger gets a pairing code reply.
2. bridge.js had no mode check on !fromMe messages, so self-chat mode
(where the operator only wants to talk to themselves) forwarded
everything anyway.
Fix:
- allowlist.js: empty allowlist now returns false. Operators who want
an open bot must set WHATSAPP_ALLOWED_USERS=* explicitly (the
existing wildcard behaviour, consistent with SIGNAL_GROUP_ALLOWED_USERS).
- bridge.js: self-chat mode hard-rejects all !fromMe messages at the
bridge, before they ever reach the Python gateway. Bot mode still
enforces the allowlist.
- Startup log message updated to reflect the new per-mode behaviour
(was '⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be
processed', which was both inaccurate post-fix and a bad default
signal pre-fix).
- allowlist.test.mjs: new regression test pinning the empty-rejects
contract, + null/undefined defensive cases.
Behaviour delta for existing users:
- self-chat mode, no allowlist: strangers got pairing codes, now
silently dropped. Strictly better.
- bot mode, no allowlist: strangers got pairing codes via the
Python-side pairing flow, now silently dropped at the JS bridge.
Operators who genuinely want an open bot set
WHATSAPP_ALLOWED_USERS=*.
* feat(slack): add allowed_channels whitelist config
* chore(release): add CashWilliams to AUTHOR_MAP
* feat(gateway): add allowed_{chats,channels,rooms} whitelist to Telegram, Mattermost, Matrix, DingTalk
Mirrors the Slack `allowed_channels` feature (PR #7401) and Discord's
`allowed_channels` (PR #7044) across the remaining group-capable platforms.
All five platforms (Slack + Discord + the four added here) now follow the
same pattern: primary config via config.yaml, env-var fallback as an escape
hatch — matching the project policy that .env is for secrets only and
behavioral settings belong in config.yaml.
Also fixes a duplicate `slack` key in DEFAULT_CONFIG introduced by PR
#7401 (the later entry silently overwrote `allowed_channels`, `require_mention`,
and `free_response_channels` at dict-literal evaluation time).
Platforms added:
- Telegram: `telegram.allowed_chats` (env alias: `TELEGRAM_ALLOWED_CHATS`)
- Mattermost: `mattermost.allowed_channels` (env alias: `MATTERMOST_ALLOWED_CHANNELS`)
- Matrix: `matrix.allowed_rooms` (env alias: `MATRIX_ALLOWED_ROOMS`)
- DingTalk: `dingtalk.allowed_chats` (env alias: `DINGTALK_ALLOWED_CHATS`)
Mattermost and Matrix previously had NO config.yaml bridging for any of
their gating settings; this PR adds `load_gateway_config` bridges for them
(Mattermost gets require_mention + free_response_channels + allowed_channels;
Matrix gets allowed_rooms on top of its existing bridges for require_mention
and free_response_rooms).
Semantics identical everywhere:
- Empty = no restriction (fully backward compatible).
- Non-empty = hard whitelist: non-listed chats are silently ignored,
even when the bot is @mentioned.
- DMs bypass the check entirely.
DEFAULT_CONFIG merges the duplicate `slack` block and adds new `mattermost`
and `matrix` blocks so all gating settings surface in defaults.
Not included: Feishu (has its own per-chat `chat_rules` system that covers
this use case differently), WhatsApp (already has `group_allow_from` via
`group_policy: allowlist`), pure-DM platforms (Signal, SMS, BlueBubbles,
Yuanbao — no group concept).
* fix: strip Codex-hostile top-level schema combinators
* fix(memory): remove dead allOf schema block at the source
PR #21238 introduced top-level `allOf: [{if/then/required}]` blocks in the
built-in memory tool's parameters schema as conditional-required hints.
Two problems:
1. OpenAI's Codex backend (chatgpt.com/backend-api/codex, gpt-5.x) rejects
top-level `allOf`/`anyOf`/`oneOf`/`enum`/`not` outright with a
non-retryable 400 — affected every user on openai-codex/gpt-5.x.
2. The `if/then` hints were silently ignored by every other provider
(Chat Completions doesn't honour them on function schemas), so they
never actually enforced anything anywhere.
The runtime handler in `memory_tool()` already validates the per-action
required fields and returns actionable error messages, so removing the
block changes nothing behaviourally.
Paired with the defense-in-depth sanitizer in the previous commit, this
closes the bug both at the source (schema no longer emits the forbidden
form) and at the wire boundary (sanitizer strips it if anything else
re-introduces it).
- Rewrites `tests/tools/test_memory_tool_schema.py` to guard against
regressing the forbidden-combinator shape instead of asserting it.
- Adds AUTHOR_MAP entry for @hrkzogw (author of the sanitizer fix).
* fix(mcp): re-raise CancelledError explicitly in MCPServerTask.run (#21318)
On Python 3.11+, `asyncio.CancelledError` inherits from `BaseException`
(not `Exception`), so the broad `except Exception as exc:` in
`MCPServerTask.run`'s transport loop did NOT catch it. Task cancellation
from gateway restart / explicit `task.cancel()` silently escaped past
the reconnect logic — the MCP server task died without going through
the shutdown/reconnect code paths that check `_shutdown_event`.
Add an explicit `except asyncio.CancelledError: raise` before the broad
catch so cancellation propagation is self-documenting rather than an
accident of exception hierarchy, and future sibling-site work (e.g.
distinguishing shutdown-cancel from transport-cancel) has an obvious
hook. Behavior on pre-3.8 Pythons where CancelledError WAS an Exception
subclass is also corrected: the old path would have caught it and
treated it as a connection failure worth retrying.
Closes #9930.
* fix(dashboard): finish resumeId -> resumeParam rename in ChatPage (#21317)
Commit b12a5a72b renamed the local variable resumeId -> resumeParam at
line 157 but left two call sites referencing the old name at lines 555
and 660. tsc -b fails with two TS2304 errors, which tanks npm run build,
which makes `hermes dashboard` print "Web UI build failed" with no
further detail.
Finishes the rename at both call sites instead of re-introducing the
old name via an alias.
Co-authored-by: qiuqfang <qiuqfang98@qq.com>
* fix(tests): avoid asyncio DeprecationWarning in event loop fixture on 3.12+
* chore(release): map tuancanhnguyen706@gmail.com → xxxigm
* fix(mcp): forward OAuth auth and bump sse_read_timeout on SSE transport (#21323)
* fix(mcp): re-raise CancelledError explicitly in MCPServerTask.run
On Python 3.11+, `asyncio.CancelledError` inherits from `BaseException`
(not `Exception`), so the broad `except Exception as exc:` in
`MCPServerTask.run`'s transport loop did NOT catch it. Task cancellation
from gateway restart / explicit `task.cancel()` silently escaped past
the reconnect logic — the MCP server task died without going through
the shutdown/reconnect code paths that check `_shutdown_event`.
Add an explicit `except asyncio.CancelledError: raise` before the broad
catch so cancellation propagation is self-documenting rather than an
accident of exception hierarchy, and future sibling-site work (e.g.
distinguishing shutdown-cancel from transport-cancel) has an obvious
hook. Behavior on pre-3.8 Pythons where CancelledError WAS an Exception
subclass is also corrected: the old path would have caught it and
treated it as a connection failure worth retrying.
Closes #9930.
* fix(mcp): forward OAuth auth and bump sse_read_timeout on SSE transport
Two surgical correctness bugs in the SSE branch of MCPServerTask._run_http,
distilled from @amiller's PR #5981 that couldn't be cherry-picked wholesale
(branch too stale).
1. sse_read_timeout was set to the tool timeout (default 60s). That's the
wrong dimension — it governs how long sse_client will wait between
events on the SSE stream, not per-call latency. SSE servers routinely
hold the stream idle for minutes between events; a 60s read timeout
drops the connection after the first slow stretch (Router Teamwork,
Supermemory on Cloudflare Workers idle-disconnect at ~60s). Bump to
300s to match the Streamable HTTP path's httpx read timeout.
2. OAuth auth was built via get_manager().get_or_build_provider() but
never forwarded to sse_client. SSE MCP servers behind OAuth 2.1 PKCE
would silently fail with 401s on every request.
Keepalive (the other half of #5981) intentionally left for a follow-up —
it's a real improvement but a bigger change, and these two are obvious
corrections to ship now. Credits to @amiller.
Co-authored-by: Andrew Miller <socrates1024@gmail.com>
---------
Co-authored-by: Andrew Miller <socrates1024@gmail.com>
* fix(mcp): surface image tool results as MEDIA tags instead of dropping them (#21328)
MCP tool results can include ImageContent blocks (screenshots from
Playwright/Blockbench/Puppeteer etc). The tool result handler only
extracted block.text, so image blocks were silently dropped and the
agent saw an empty or text-only response — losing the actual payload.
Add _cache_mcp_image_block() that base64-decodes the block, validates
the bytes via gateway.platforms.base.cache_image_from_bytes (which
sniffs for PNG/JPEG/WebP signatures and rejects non-images), writes to
the shared `~/.hermes/cache/images/` dir, and returns a MEDIA:<path>
tag. The handler appends that tag to the result parts so downstream
gateway adapters render the image inline.
Logs and drops on malformed base64 / non-image payload rather than
raising — a single bad block shouldn't kill the tool call.
Distilled from #17915 (c3115644151) and #10848 (gnanirahulnutakki), both
too stale to cherry-pick (branches diverged enough to revert dozens of
unrelated fixes). Went with #10848's approach of plumbing through
Hermes' existing MEDIA tag / cache_image_from_bytes infrastructure
rather than #17915's raw tempfile path, because it integrates with the
remote-backend mount system and messaging adapters that already handle
MEDIA tags natively.
Co-authored-by: c3115644151 <c3115644151@users.noreply.github.com>
Co-authored-by: gnanirahulnutakki <gnanirahulnutakki@users.noreply.github.com>
* feat(gateway): generic plugin hooks for env enablement + cron delivery
Widen the platform-plugin surface so plugins can self-configure from env
vars and opt into cron home-channel delivery without editing core files.
Closes the scope gap that forced every new platform (Google Chat, Teams,
IRC, future) to either touch gateway/config.py, cron/scheduler.py, and
hermes_cli/config.py or live without env-only setup.
Changes:
- gateway/platform_registry.py: two new optional PlatformEntry fields.
- env_enablement_fn: () -> Optional[dict]. Called during
_apply_env_overrides BEFORE the adapter is constructed. Returned
dict fields are merged into PlatformConfig.extra; the special
'home_channel' key (if present) becomes a proper HomeChannel
dataclass on the PlatformConfig.
- cron_deliver_env_var: name of the *_HOME_CHANNEL env var. When set,
the plugin platform is a valid cron deliver= target and cron reads
the env var to resolve the default chat/room ID.
- gateway/config.py: the existing plugin-platform enable pass at the
bottom of _apply_env_overrides now calls env_enablement_fn and seeds
extras/home_channel. No effect on plugins that don't set the new
field.
- cron/scheduler.py: _is_known_delivery_platform and
_resolve_home_env_var fall through to the registry when the platform
isn't in the hardcoded built-in sets. New _iter_home_target_platforms
helper iterates built-ins + plugin platforms for the deliver=origin
fallback.
- gateway/run.py: _home_target_env_var now consults the new resolver so
plugin-defined home channels work for non-cron call sites too.
- hermes_cli/config.py: new _inject_platform_plugin_env_vars() sibling
of _inject_profile_env_vars(). Scans plugins/platforms/*/plugin.yaml
at import time and contributes entries to OPTIONAL_ENV_VARS so
'hermes config' UI discovers them. Supports bare-string and rich-dict
requires_env entries plus a new optional_env list for non-required
vars (home channels, allowlists).
All additions are strictly opt-in. Existing plugins (IRC, Teams,
image_gen, memory) see zero behavior change until they adopt the new
fields.
* feat(plugins/google_chat): Google Chat platform adapter as a bundled plugin
Adds Google Chat as a new gateway platform, shipped under
plugins/platforms/google_chat/ following the canonical bundled-plugin
pattern (Teams, IRC). Rewired from the original PR #18425 to use the
new env_enablement_fn + cron_deliver_env_var plugin interfaces landed
in the preceding commit, so the adapter touches ZERO core files.
What it does:
- Inbound DM + group messages via Cloud Pub/Sub pull subscription (no
public URL needed), with attachments (PDFs, images, audio, video)
downloaded through an SSRF-guarded Google-host allowlist.
- Outbound text replies with the 'Hermes is thinking…' patch-in-place
pattern — no tombstones.
- Native file attachment delivery via per-user OAuth. Google Chat's
media.upload endpoint rejects service-account auth, so each user
runs /setup-files once in their own DM to grant
chat.messages.create for themselves; the adapter then uploads as
them. Tokens stored per email at
~/.hermes/google_chat_user_tokens/<email>.json.
- Thread isolation: side-threads get isolated sessions, top-level DM
messages share one continuous session. Persistent thread-count
store survives gateway restart.
- Supervisor reconnect with exponential backoff.
- Multi-user out of the box.
How it plugs in (no core edits):
- env_enablement_fn seeds PlatformConfig.extra with project_id,
subscription_name, service_account_json, and the home_channel dict
(which the core hook turns into a HomeChannel dataclass). Reads
GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT),
GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION),
GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to
GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL.
- cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery
for free — cron/scheduler.py consults the platform registry for any
name not in its hardcoded built-in sets.
- plugin.yaml's rich requires_env / optional_env blocks auto-populate
OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so
'hermes config' UI surfaces them with description / url / prompt /
password metadata.
- Module-level Platform('google_chat') call in adapter.py triggers the
Platform._missing_() registration so Platform.GOOGLE_CHAT attribute
access works without an enum entry.
Distribution: ships inside the existing hermes-agent package. Users
opt in via 'pip install hermes-agent[google_chat]' and follow the
8-step GCP walkthrough at
website/docs/user-guide/messaging/google_chat.md.
Test coverage: 153 tests in tests/gateway/test_google_chat.py, all
passing. Spans platform registration, env config loading, Pub/Sub
envelope routing, outbound send + chunking + typing patch-in-place,
attachment send paths, SSRF guard, thread/session model,
supervisor reconnect, authorization, per-user OAuth, and the new
plugin-registry cron delivery wiring.
Credit: adapter + OAuth + tests + docs authored by @donramon77
(PR #18425). Rewire onto the new plugin hooks + salvage commit by
Teknium.
Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
* refactor(plugins/platforms): migrate IRC + Teams to new env_enablement + cron_deliver hooks
Adopt the generic platform-plugin hooks landed in the preceding commit
so IRC and Teams get env-only config detection and cron home-channel
delivery without living in cron/scheduler.py's hardcoded sets.
IRC (plugins/platforms/irc/):
- adapter.py: new _env_enablement() seeds server, channel, port,
nickname, use_tls, server_password, nickserv_password, and a
home_channel dict into PlatformConfig on env-only setups.
IRC_HOME_CHANNEL defaults to IRC_CHANNEL so deliver=irc cron jobs
route to the joined channel by default.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='IRC_HOME_CHANNEL'.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every IRC env var. Hardcoded IRC entries
in hermes_cli/config.py still win (back-compat), but the plugin now
carries its own metadata.
Teams (plugins/platforms/teams/):
- adapter.py: new _env_enablement() seeds client_id, client_secret,
tenant_id, port, and home_channel into PlatformConfig. Closes the
long-standing gap where TEAMS_HOME_CHANNEL was documented but never
wired up.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='TEAMS_HOME_CHANNEL' — deliver=teams cron
jobs now work.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every Teams env var. Surfaces them in
'hermes config' UI for the first time (Teams had no OPTIONAL_ENV_VARS
entries before this).
Zero behavior change for existing users: env_enablement_fn is only
called when env vars are set, and the registry's config-first-env-fallback
path in validate_config / is_connected is unchanged.
* chore(release): map donramon77 to AUTHOR_MAP for PR #18425 salvage
* fix(mcp): coerce numeric tool args defensively
* chore(release): add qWaitCrypto to AUTHOR_MAP for PR #21055 salvage
* fix(pairing): enforce lockout on approve_code, not just generate_code (#10195) (#21325)
PairingStore.approve_code() didn't consult _is_locked_out(), so after
MAX_FAILED_ATTEMPTS bad approvals the lockout flag was set but a valid
code still got accepted — any pending code (legitimately issued or
attacker-obtained) could be approved during the 1-hour lockout window,
nullifying the brute-force protection.
- gateway/pairing.py: lockout check runs in approve_code() right after
_cleanup_expired, before the pending lookup. Returns None on lockout.
- tests/gateway/test_pairing.py: test_lockout_blocks_code_approval pins
the regression — reporter's exact reproducer (generate valid code,
exhaust attempts with WRONGCODE, try to approve valid code) must
return None and leave is_approved == False. Also pins recovery: once
lockout expires, the still-pending code approves normally.
- hermes_cli/pairing.py: _cmd_approve distinguishes the two None cases.
On lockout, prints 'Platform locked out... clears in N minutes. To
reset sooner, delete the _lockout:<platform> entry from
_rate_limits.json' instead of the misleading 'Code not found or
expired' message. 29/29 pairing tests pass; E2E-verified with
reporter's exact Python reproducer.
* docs(readme): prefer .venv to match AGENTS.md and scripts/run_tests.sh (#21334)
* feat(kanban): per-task max_retries override (#20263 follow-up, supersedes #20972) (#21330)
Adds a per-task override for the consecutive-failure circuit breaker,
so individual tasks can opt out of the global ``kanban.failure_limit``
without dragging everyone else with them.
Resolution order (now three tiers):
1. per-task ``max_retries`` (new, this commit)
2. caller-supplied ``failure_limit`` — the gateway threads
``kanban.failure_limit`` from config here
3. ``DEFAULT_FAILURE_LIMIT`` (2)
Changes:
- ``tasks.max_retries INTEGER`` column + migration for existing DBs
(NULL = no override, matches pre-column behavior).
- ``Task.max_retries`` field + ``from_row`` plumbing.
- ``create_task(..., max_retries=N)`` kwarg.
- ``_record_task_failure`` reads the per-task value first and records
``limit_source`` + ``effective_limit`` on the ``gave_up`` event so
operators can see which tier won.
- CLI: ``hermes kanban create --max-retries N`` (rejects ``< 1``).
- CLI: ``hermes kanban show`` surfaces the effective threshold +
source (``(task)``, ``(config kanban.failure_limit)``, ``(default)``).
- CLI: ``_task_to_dict`` includes ``max_retries`` in ``--json`` output.
Key design choice vs. the earlier #20972 attempt:
- No new config key. The existing ``kanban.failure_limit`` (landed in
#21183) is the dispatcher-tier source — no silent break for users
who already tuned it.
- No ``!=`` sentinel for "is config set" (which would misfire when
config equals the default). The tier-winner is determined purely
by "is per-task override set" — the dispatcher always wins when
per-task is NULL, regardless of whether the caller passed the
default or a configured value.
E2E verified across four scenarios: default-only (trips at 2),
config-only (trips at caller's value), per-task-only beats default
(trips at task value), per-task beats larger config (trips at task
value). ``gave_up`` event metadata correctly records ``limit_source``
and ``effective_limit`` in all cases.
Tests:
- ``test_per_task_max_retries_overrides_dispatcher_limit`` — task=1
beats caller=10.
- ``test_per_task_max_retries_allows_more_than_default`` — task=5
does not trip at caller=default of 2.
- ``test_max_retries_none_falls_through_to_dispatcher_limit`` — None
honors caller's config value (4), records ``limit_source=dispatcher``.
Full kanban trio (db + core + cli + tools + dashboard-plugin): 342
passed, no regressions.
Supersedes: #20972 (@jelrod27) — credit in PR close comment.
Ref: #20263 (tangentially — the reporter asked about adapter API
drift, not retry caps, but the CLI discussion there is what
surfaced the original ask).
* feat(qqbot): add chunked upload with structured error types
The v2 'single POST /v2/{users|groups}/{id}/files' upload path is capped
at ~10 MB inline (base64 'file_data' or 'url'). For larger files the QQ
platform provides a three-step flow:
1. POST /upload_prepare → upload_id + pre-signed COS part URLs
2. PUT each part to its COS URL → POST /upload_part_finish
3. POST /files with {upload_id} → file_info token
This commit adds a new gateway/platforms/qqbot/chunked_upload.py module
that implements the flow, wires it into QQAdapter._send_media for local
files (URL uploads keep the existing inline path), and introduces
structured exceptions so the caller can surface actionable error text:
- UploadDailyLimitExceededError (biz_code 40093002, non-retryable)
- UploadFileTooLargeError (file exceeds the platform limit)
Both carry file_name / file_size_human / limit_human so the model can
compose user-friendly replies instead of seeing opaque HTTP codes.
The part_finish 40093001 retryable-error loop respects the server-
provided retry_timeout (capped at 10 minutes locally) with a 1 s
polling interval. COS PUTs retry transient failures up to 2 times
with exponential backoff. complete_upload retries up to 2 times.
Covers files up to the platform's ~100 MB per-file limit; before this
the adapter silently rejected anything over ~10 MB.
19 new unit tests under TestChunkedUpload* cover the happy path,
prepare-response parsing, helper functions, part retries, COS PUT
retries, group vs c2c routing, and the structured-error mapping.
Co-authored-by: WideLee <limkuan24@gmail.com>
* feat(qqbot): add inline-keyboard approvals and update prompts
The QQ Bot v2 API supports inline keyboards on outbound messages. When a
user taps a button, the platform dispatches an INTERACTION_CREATE
gateway event; the bot ACKs it via PUT /interactions/{id} and decodes
the button's data payload to route the click.
This commit adds:
New module gateway/platforms/qqbot/keyboards.py
- Inline-keyboard dataclasses (InlineKeyboard, KeyboardRow, KeyboardButton,
KeyboardButtonAction, KeyboardButtonRenderData, KeyboardButtonPermission)
that serialize to the JSON shape the QQ API expects.
- build_approval_keyboard(session_key) — 3-button layout:
✅ 允许一次 / ⭐ 始终允许 / ❌ 拒绝, all sharing group_id='approval'
so clicking one greys out the rest.
- build_update_prompt_keyboard() — Yes/No keyboard for update confirms.
- parse_approval_button_data() / parse_update_prompt_button_data() —
decode the button_data payload from INTERACTION_CREATE.
approve:<session_key>:<decision> (decision = allow-once|allow-always|deny)
update_prompt:<answer> (answer = y|n)
- build_approval_text(ApprovalRequest) — markdown renderer for the
surrounding message body (exec-approval and plugin-approval variants,
with severity icons 🔴/🔵/🟡).
- parse_interaction_event(raw) → InteractionEvent dataclass — normalizes
the nested raw payload (id / scene / openids / button_data / etc.).
Adapter changes (gateway/platforms/qqbot/adapter.py)
- _dispatch_payload routes INTERACTION_CREATE → _on_interaction.
- _on_interaction parses the event, ACKs via PUT /interactions/{id}, then
invokes a user-registered interaction callback. Exceptions from the
callback are caught and logged (never propagate into the WS loop).
- set_interaction_callback(cb) lets gateway wiring register a routing
handler that inspects button_data and resolves the corresponding
pending approval / update prompt.
- _send_c2c_text / _send_group_text now accept an optional keyboard kwarg
and append it to the outbound body.
- send_with_keyboard(chat_id, content, keyboard, reply_to=None) — public
helper that sends a single short message with a keyboard attached.
Does NOT chunk-split (a keyboard message has one interactive surface).
Guild chats are rejected non-retryably — they don't support keyboards.
- send_approval_request(chat_id, ApprovalRequest, reply_to=None) +
send_update_prompt(chat_id, content, reply_to=None) — convenience
wrappers over send_with_keyboard.
Tests
27 new unit tests under TestApprovalButtonData, TestUpdatePromptButtonData,
TestBuildApprovalKeyboard, TestBuildUpdatePromptKeyboard, TestBuildApprovalText,
TestInteractionEventParsing, and TestAdapterInteractionDispatch. Cover:
- Button-data round-trip (build → parse returns original session/decision)
- Keyboard JSON shape + mutual-exclusion group_id
- Exec vs plugin approval text templates + severity icons
- Interaction event parsing (c2c / group / guild scene codes)
- _on_interaction end-to-end: ACK invoked, callback receives parsed event,
callback exceptions are swallowed, missing id skips ACK, no registered
callback is harmless.
Full qqbot suite: 118 passed (72 existing + 19 chunked + 27 keyboards).
Co-authored-by: WideLee <limkuan24@gmail.com>
* feat(qqbot): process attachments in quoted (reply) messages
When a user replies while quoting another message, QQ sets
'message_type = 103' and pushes the referenced message's content +
attachments inside 'msg_elements[0]'. The old adapter ignored
msg_elements entirely, so:
- Bare quote-replies (no user text) surfaced nothing to the LLM.
- Quoted images/files/voice were never downloaded or described.
- Quoted voice messages specifically produced no transcript — the model
had no way to see what the user was referring to when saying 'about
this voice note…'.
This commit adds _process_quoted_context(d) which extracts msg_elements,
unions their attachments, and runs them through the SAME
_process_attachments pipeline as the main message body. Quoted voice
gets an STT transcript (tried via QQ's asr_refer_text first, then the
configured STT provider); quoted images get cached just like main-body
images; quoted files surface with their original filename intact (not
the CDN URL hash).
The quoted content is prepended to the user's text as a '[Quoted message]:'
block so the LLM sees the full referential context on one turn.
Images-only quotes surface a '[Quoted message]: (image)' marker so the
model knows an image was referenced even if no text came with it.
All four inbound handlers (_handle_c2c_message, _handle_group_message,
_handle_guild_message, _handle_dm_message) now call the helper uniformly
— one merge pattern, not four divergent implementations.
Filename preservation is carried by _process_attachments' existing
'[Attachment: {filename or ct}]' line; nothing else needed for that.
12 new tests under TestProcessQuotedContext and TestMergeQuoteInto cover:
- Non-quote messages short-circuit to empty
- message_type=103 with no msg_elements is harmless
- Text-only quotes render with '[Quoted message]:' prefix
- Voice attachments in the quote flow through STT
- File attachments in the quote preserve the original filename
- Image attachments surface cached paths + media types
- Images-only quote still emits a marker
- Multiple msg_elements are concatenated
- Malformed message_type values return empty
- _merge_quote_into prepends with a blank-line separator
Full qqbot suite: 130 passed (72 existing + 19 chunked + 27 keyboards
+ 12 quoted).
Co-authored-by: WideLee <limkuan24@gmail.com>
* docs(platforms): document env_enablement_fn + cron_deliver_env_var hooks (#21331)
Following PR #21306 which added the new generic plugin-platform hooks,
update the three platform-authoring docs so plugin authors find them:
- website/docs/developer-guide/adding-platform-adapters.md: expand the
'What the Plugin System Handles Automatically' table with env-only
auto-enable + cron delivery + hermes-config UI entries rows. Add
three new sections — 'Env-Driven Auto-Configuration', 'Cron
Delivery', 'Surfacing Env Vars in hermes config' — covering the
hook signatures, plugin.yaml rich-dict format, and the
home_channel-key special case. Update the main register() example
to pass env_enablement_fn + cron_deliver_env_var inline so readers
see them on their first pass. Upgrade the PLUGIN.yaml snippet to
show bare-string + rich-dict + optional_env.
- website/docs/guides/build-a-hermes-plugin.md: the thin platform
example in the build-a-plugin tour now includes env_enablement_fn
and cron_deliver_env_var, plus an optional_env block in the inline
plugin.yaml. Keeps pointing to the developer-guide page for the
full treatment.
- gateway/platforms/ADDING_A_PLATFORM.md: the in-repo reference
shallow-points at the docsite but now names the three new hooks
explicitly so contributors reading the source tree know what
they're for. Also adds teams + google_chat as reference
implementations alongside irc.
* fix: block INSECURE_NO_AUTH on non-localhost webhook bindings
* fix(webhook): widen INSECURE_NO_AUTH loopback check + tests + docs
Follow-up to the previous commit:
- Add _is_loopback_host() helper covering 127.0.0.1, localhost, ::1,
ip6-localhost, ip6-loopback (case-insensitive). Empty/None host is
treated as non-loopback since unset usually means public default bind.
- Fix mixed-indent comment in the safety rail (comment now aligned with
the if-block) and collapse the nested-if into one condition.
- Add TestInsecureNoAuthSafetyRail covering rejection on 0.0.0.0, a LAN
IP, and empty host; allowance on 127.0.0.1/localhost; plus unit-level
parametrized coverage of _is_loopback_host for spellings we can't bind
in the hermetic test env (::1, ip6-localhost, ip6-loopback).
- Pin test_connect_starts_server + test_webhook_deliver_only defaults
to 127.0.0.1 so they keep passing under the new rail.
- Document the behavior in website/docs/user-guide/messaging/webhooks.md.
* fix(mcp): gate utility stubs on server-advertised capabilities (#21347)
For every connected MCP server we register four "utility" tool schemas
(mcp_<server>_list_resources, read_resource, list_prompts, get_prompt).
The existing gate was `hasattr(server.session, method)` — but
`mcp.ClientSession` defines all four methods on the class regardless of
what the remote server supports, so the gate never filtered anything.
Tools-only servers (e.g. @upstash/context7-mcp which advertises only
`tools`) ended up with 4 dead stubs; every model call to them returned
JSON-RPC -32601 Method not found, which made the model conclude the
server was broken even when the real tools worked.
Capture the `InitializeResult` returned by `await session.initialize()`
on the `MCPServerTask`, then gate each utility schema on the
corresponding `capabilities` sub-object (resources / prompts). A
legacy `hasattr` fallback runs when `initialize_result` is missing
(older test fixtures / not-yet-captured code paths) so pre-existing
behavior is preserved.
Verified against real `mcp.types.InitializeResult` pydantic models:
- Context7 shape (tools only) → 0 utility stubs registered (was 4)
- Resources-only server → 2 stubs (list_resources, read_resource)
- Prompts-only server → 2 stubs (list_prompts, get_prompt)
- Fully capable server → all 4 stubs
Closes #18051.
Co-authored-by: nikolay-bratanov <nikolay-bratanov@users.noreply.github.com>
* test(kanban): cover dashboard select filter wiring
* fix(kanban): filter dashboard board by selected tenant
* chore(release): map maciekczech noreply email
* fix(cron): scan assembled prompt including skill content (#3968) (#21350)
_scan_cron_prompt ran at cron create/update time on the user-supplied
prompt but skill content loaded inside _build_job_prompt at runtime
was never scanned. Combined with non-interactive auto-approval, a
malicious skill carrying an injection payload could execute with full
tool access every tick.
- cron/scheduler.py: new CronPromptInjectionBlocked exception and
_scan_assembled_cron_prompt helper. _build_job_prompt now routes
both return paths (with skills / without skills) through the helper,
raising on match. run_job catches the exception and returns a clean
(False, blocked_doc, "", error) tuple so the operator sees a BLOCKED
delivery with the scanner result and an audit hint, rather than a
scheduler crash or a silent skip.
- tests/cron/test_cron_prompt_injection_skill.py: 10 regression tests.
Unit coverage on _scan_assembled_cron_prompt (clean/injection/exfil/
invisible-unicode). End-to-end coverage via _build_job_prompt with
planted skills (injection payload, env exfil, zero-width space,
clean control, missing-skill-doesn't-crash). Fixture patches
tools.skills_tool.SKILLS_DIR / HERMES_HOME so planted skills are
visible. Importantly uses the current cron.scheduler module object
(not a top-level import) so tests don't break when other fixtures
reload cron.scheduler — CronPromptInjectionBlocked identity depends
on which module object defined it.
* feat(qqbot): wire native tool-approval UX via inline keyboards
Makes the in-tree QQ inline keyboards actually light up when the agent
blocks on a dangerous-command approval. Matches the cross-adapter
gateway contract already implemented by Discord, Telegram, Slack,
Matrix, and Feishu.
Gateway/run.py's _approval_notify_sync checks type(adapter).send_exec_approval
and falls back to a text prompt when it's missing. Without this wiring,
QQ users stared at plain '/approve' text even though the adapter shipped
button primitives.
### send_exec_approval(chat_id, command, session_key, description, metadata)
Matches the signature the gateway calls with. Builds an ApprovalRequest
(command_preview, description, timeout) and delegates to send_approval_request.
Uses the last inbound msg_id as reply_to so QQ accepts the passive
message. The 'metadata' parameter is accepted for contract parity but
intentionally unused — QQ doesn't have thread_id/DM-targeting overrides.
### send_update_prompt(chat_id, prompt, default, session_key, metadata)
Signature updated to match the cross-adapter contract used by
'hermes update --gateway' watcher. Renders a 'Update Needs Your Input'
prompt with the optional default hint and a Yes/No keyboard. Replaces
the earlier 3-arg helper that wasn't wired anywhere.
### Default interaction dispatcher
_default_interaction_dispatch() auto-registered as the adapter's
interaction callback in __init__. Routes:
- approve:<session_key>:<decision> → tools.approval.resolve_gateway_approval
Button → choice mapping:
allow-once → 'once'
allow-always → 'always'
deny → 'deny'
(QQ's 3-button mobile layout deliberately collapses 'session' + 'always'
into one button; /approve session text fallback remains available.)
- update_prompt:<answer> → atomic write of y/n to ~/.hermes/.update_response
(the detached 'hermes update --gateway' watcher polls this file)
- anything else → logged and dropped
Resolve exceptions are caught and logged — never propagate into the WS
loop. Callers can override via set_interaction_callback() to route
clicks elsewhere or pass None to drop them entirely.
### Net effect
QQ users now get native tap-to-approve UX on dangerous-command prompts
and update-confirmation prompts, without having to type /approve or /deny
as text. The adapter hooks into tools.approval the same way every other
button-capable platform does.
### Tests
14 new tests cover:
- Default callback installed on __init__
- send_exec_approval / send_update_prompt exist as class methods (so the
gateway's type-probe detects them)
- allow-once/always/deny each map to the correct resolve choice
- update_prompt:y / update_prompt:n each write atomically to the response
file (via monkeypatched get_hermes_home)
- Unknown button_data / empty button_data / resolve exceptions are harmless
- send_exec_approval honours last_msg_id reply-to and accepts metadata
- send_update_prompt delegates with correct content + keyboard
Full qqbot suite: 144 passed (72 pre-existing + 72 from this salvage arc).
Also ran tools/test_approval.py alongside — no regressions (276 passed
combined).
Co-authored-by: WideLee <limkuan24@gmail.com>
* fix(cron): initialize MCP servers before constructing the cron AIAgent (#21354)
cron/scheduler.py:run_job() constructed AIAgent(...) without ever calling
discover_mcp_tools(). The CLI and gateway paths do this at startup; cron
jobs inherited none of it and the user's configured mcp_servers were
invisible inside every cron run.
Insert discover_mcp_tools() right before AIAgent(), wrapped in try/except
so a broken MCP server can't kill an otherwise-working cron job. The call
is idempotent: register_mcp_servers() short-circuits on already-connected
servers, so subsequent ticks in the same scheduler process pay ~0ms.
Scoped to the LLM path only; no_agent script jobs skip it entirely.
Closes #4219.
* fix(update): reset-failed before every fallback restart so the gateway can't get stranded (#21371)
cmd_update's auto-restart path could leave the gateway dead after a
transient failure in systemd's own auto-restart window. Reproduced
on Ubuntu 25.10 + systemd 257: after update, gateway drains and exits 75,
systemd's first respawn 60s later fails (status=200/CHDIR with
"No such file or directory" on a WorkingDirectory that demonstrably
exists), the unit ends up in RestartMaxDelaySec=300 backoff, and
cmd_update's fallback 'systemctl restart' never recovers it — leaving
users with a permanently silent gateway until they manually run
'systemctl reset-failed'.
The fix mirrors the recovery pattern 'hermes gateway restart'
(systemd_restart) got in PR #20949: always reset-failed before
restart, on both the initial fallback and the retry. Also rewrites
the final failure message to tell the user to reset-failed +
restart (not just restart, which is the step that already failed
twice).
* fix(run_agent): break permanent empty-response loop from orphan tool-tail (#21385)
When empty-response terminal scaffolding fires on a tool-result turn,
_drop_trailing_empty_response_scaffolding left the live history ending at
a bare 'tool' message. The next user input then landed as [...tool, user],
a protocol-invalid sequence that OpenRouter/Opus and other providers
silently fail on (returns empty content). That retriggered the empty-retry
recovery every turn, and recovery flags never hit SQLite (no column for
them), so history kept looking broken on every reload.
Two fixes:
1. Scaffolding strip rewinds the orphan assistant(tool_calls)+tool pair
after popping sentinels. Only fires when scaffolding flags were
actually present, so mid-iteration tool loops are untouched.
2. _repair_message_sequence runs right before every API call as a
defensive belt: drops stray tool messages with unknown tool_call_ids,
merges consecutive user messages so no user input is lost. Does NOT
rewind assistant(tool_calls)+tool+user — that pattern is valid when
the user redirected before the model got its continuation turn.
Repro: session 20260507_044111_fa7e65. Opus-4.7/OpenRouter returned
content-less response after a 42KB execute_code output, nudge+retry
chain exhausted (no fallback configured), terminal sentinel appended,
scaffolding stripped leaving bare tool tail, user typed 'wtf happened..'
and landed as tool→user violation. Every subsequent turn collapsed in
<50ms with the same 3-retry empty chain because the API request itself
was malformed.
Verified live via HTTP mock: pre-fix reproduced 5 api_calls/0.15s exit
'empty_response_exhausted'; post-fix 1 api_call/0.10s exit
'text_response(finish_reason=stop)'. Three-turn session flows cleanly
through the scenario. Full run_agent suite: 1242 passed (0 regressions,
2 pre-existing concurrent_interrupt failures unrelated).
* fix(telegram): preserve thread_id=1 for forum General typing indicator (#21390)
The May 5 refactor in d5357f816 made _message_thread_id_for_typing()
symmetric with _message_thread_id_for_send() by mapping the General
topic (thread id "1") to None upfront for both. That's correct for
sendMessage — Telegram rejects message_thread_id=1 on sends and the
topic must be omitted — but it's wrong for sendChatAction.
Observed behavior (confirmed via before/after Telegram wire traces):
Before d5357f816: thread_id=1 → message_thread_id=1 → bubble visible in General
After d5357f816: thread_id=1 → message_thread_id=None → no visible typing
Omitting message_thread_id on sendChatAction does NOT fall back to
the General topic's view in a forum-enabled supergroup; the bubble
ends up hidden from the client's General-topic pane entirely. For
any user on a forum-group, the typing indicator stopped appearing.
Fix: drop the symmetric "1 → None" mapping from the typing resolver.
sendMessage still maps 1 → None via _message_thread_id_for_send (that
side was never broken). The asymmetry is real and required by
Telegram's API — document it in the resolver docstring.
Partial revert of d5357f816; restores the behavior from 0cf7d570e
("fix(telegram): restore typing indicator and thread routing for
forum General topic"). Does not re-introduce the retry-without-thread
fallback that 41545f7ec scoped down for DM topics — with the resolver
fixed, the first call already hits the right wire shape.
Test updated from test_send_typing_general_topic_uses_none_thread_id
(which encoded the broken contract) to
test_send_typing_preserves_general_topic_thread_id, asserting the
single correct call with message_thread_id=1. 10 other tests in the
file untouched and passing.
* chore: release v0.13.0 (2026.5.7) (#21406)
The Tenacity Release — Hermes Agent now finishes what it starts.
- Durable multi-agent Kanban with heartbeat, reclaim, zombie detection,
retry budgets, hallucination gate
- /goal persistent cross-turn goals (Ralph loop)
- Checkpoints v2 single-store rewrite with real pruning
- Gateway auto-resume interrupted sessions after restart
- no_agent cron watchdog mode
- Post-write delta lint on write_file + patch
- 8 P0 security closures — redaction ON by default, CVSS 8.1 Discord
fix, WhatsApp stranger rejection, MCP/auth TOCTOU, SSRF floor,
cron prompt-injection skill scanning
- Google Chat (20th platform) + generic platform-plugin hooks
- ProviderProfile ABC + plugins/model-providers/
- 7 i18n locales (zh/ja/de/es/fr/uk/tr) + display.language
- video_analyze tool, xAI Custom Voices, SearXNG, OpenRouter caching
- MCP SSE transport + OAuth + image MEDIA surfacing
- 864 commits, 588 merged PRs, 295 contributors
* fix(acp): inline file attachment resources
* feat(acp): pass image file attachments through as image_url parts
Extends PR #21400's resource inlining with image-specific handling: ACP
resource_link and embedded blob resources with an image/* mime (or image
file suffix when mime is missing) now emit an OpenAI image_url part
with a base64 data URL, so vision models actually see the image
instead of a [Binary file omitted] note. Non-image resources keep the
existing text-inlining behavior.
Adds 3 tests: local PNG via resource_link, JPEG mime inferred from
suffix when client omits mimeType, and embedded blob PNG.
* test(hermes_constants): cover parse_reasoning_effort()
* feat(web): add Brave Search (free tier) and DDGS search providers
Both implement WebSearchProvider via tools/web_providers/ — matching the
existing SearXNG pattern (PR #5c906d702). Search-only; pair with any
extract provider via web.extract_backend.
- tools/web_providers/brave_free.py — Brave Search API (free tier, 2k
queries/mo). Uses BRAVE_SEARCH_API_KEY as X-Subscription-Token.
- tools/web_providers/ddgs.py — DuckDuckGo via the ddgs Python package.
No API key; gated on package importability.
- tools/web_tools.py: both backends added to _get_backend() config list
and auto-detect chain (trails paid providers), _is_backend_available,
web_search_tool dispatch, web_extract_tool + web_crawl_tool search-only
refusals, check_web_api_key, and the __main__ diagnostic. Introduces
_ddgs_package_importable() helper so tests can monkeypatch a single
symbol for the ddgs availability check.
- hermes_cli/tools_config.py: picker entries for both providers; ddgs
gets a post_setup handler that runs `pip install ddgs`.
- hermes_cli/config.py: BRAVE_SEARCH_API_KEY in OPTIONAL_ENV_VARS.
- scripts/release.py: AUTHOR_MAP entry for @Abd0r.
- tests: 14 new tests (brave-free) + 15 new tests (ddgs) covering
provider unit behavior, backend wiring, and search-only refusals.
Salvages the brave-free + ddgs portion of PR #19796. Not included: the
in-line helpers in web_tools.py (replaced with provider modules to match
the shipped architecture), the lynx-based extract path (these backends
should refuse extract with a clear error — users pair with a real
extract provider), and scripts/start-llama-server.sh (unrelated).
Co-authored-by: Abd0r <223003280+Abd0r@users.noreply.github.com>
* fix(update): add heartbeat during dependency install
* fix: strengthen termux install network prerequisites
* fix: add termux-all install profile and safe fallbacks
* feat: add termux doctor fallback guidance for blocked extras
* feat(kanban): add `specify` — auxiliary LLM fleshes out triage tasks (#21435)
* feat(kanban): add `specify` — auxiliary LLM fleshes out triage tasks
The Triage column shipped with a placeholder 'a specifier will flesh
out the spec', but the specifier itself was never built. This wires
it up as a dedicated CLI verb.
`hermes kanban specify <id>` calls the auxiliary LLM (configured under
`auxiliary.triage_specifier`) to expand a rough one-liner into a
concrete spec — tightened title plus a body with Goal / Approach /
Acceptance criteria / Out-of-scope sections — then atomically flips
`status: triage -> todo` and recomputes ready so parent-free tasks
go straight to the dispatcher on the same tick.
Surface:
hermes kanban specify <task_id> # single task
hermes kanban specify --all [--tenant T] # sweep triage column
hermes kanban specify ... --author NAME # audit-comment author
hermes kanban specify ... --json # one JSON line per task
Design choices:
- Parent gating is preserved. specify_triage_task flips to 'todo',
then recompute_ready promotes to 'ready' only when parents are
done — same rule as a normal parent-gated todo.
- No daemon, no background watcher. Every invocation is explicit —
keeps cost predictable and doesn't fight the dispatcher loop.
- Response parse is lenient: strict JSON preferred, markdown-fence
tolerated, raw-body fallback on malformed JSON so the LLM can't
strand a task in triage.
- All failure modes (no aux client, API error, task moved out of
triage mid-call) return SpecifyOutcome(ok=False, reason=...) so
--all continues past individual failures.
Changes:
hermes_cli/kanban_db.py + specify_triage_task()
hermes_cli/kanban_specify.py NEW (~220 LOC — prompt, parse, call)
hermes_cli/kanban.py + specify subcommand + _cmd_specify
hermes_cli/config.py + auxiliary.triage_specifier task slot
website/docs/user-guide/features/kanban.md specify + config notes
website/docs/reference/cli-commands.md CLI reference entry
tests/hermes_cli/test_kanban_specify_db.py NEW (10 tests)
tests/hermes_cli/test_kanban_specify.py NEW (20 tests)
Validation: 30/30 targeted tests pass. E2E: triage task -> specify ->
ends in 'ready' with events [created, specified, promoted] and the
audit comment recorded under the configured author.
* feat(kanban): wire specifier into dashboard and gateway slash
Follow-ups to the initial PR #21435 — closes the two gaps I'd left as
post-merge: dashboard button and first-class gateway surface.
Dashboard (plugins/kanban/dashboard/)
- POST /tasks/:id/specify NEW endpoint. Thin wrapper around
kanban_specify.specify_task(). Returns the CLI outcome shape
({ok, task_id, reason, new_title}); ok=false with a human reason
is a 200, not a 4xx, so the UI can render it inline without
treating 'no aux client configured' as a crash.
- Runs sync in FastAPI's threadpool because the LLM call can take
tens of seconds on reasoning models.
- Pins HERMES_KANBAN_BOARD around the specify call so the module's
argless kb.connect() lands on the right board.
- dist/index.js: doSpecify callback threaded through the drawer →
TaskDetail → StatusActions prop chain. ✨ Specify button appears
ONLY when task.status === 'triage' (elsewhere the backend would
reject anyway — hide the button to keep the action row clean).
Busy state (Specifying…) + inline success/error banner under the
button using the response.reason text.
- dist/style.css: tiny hermes-kanban-msg-ok / -err classes using
existing --color vars so themes reskin cleanly.
Gateway slash (/kanban specify)
- Already works via the existing run_slash → build_parser →
kanban_command pipeline. No code change needed — slash com…
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
…plugin Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR NousResearch#18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR NousResearch#18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
Summary
Adds Google Chat as a new gateway platform, shipped as a bundled plugin under
plugins/platforms/google_chat/following the canonical pattern set by Teams (#17828) and IRC.The adapter connects via Cloud Pub/Sub pull subscription for inbound events and the Google Chat REST API for outbound — same ergonomics as Slack Socket Mode or Telegram long-polling, no public URL required. Native file attachment delivery works via a per-user OAuth flow (each user runs
/setup-filesonce in their own DM) because Google Chat'smedia.uploadendpoint hard-rejects service-account auth.What you get:
chat.messages.create)/setup-filesruns the entire OAuth consent flow inside chat~/.hermes/google_chat_user_tokens/<email>.json, each user's attachments uploaded as themArchitecture
Plugin layout matches Teams' shape:
Registered via
register(ctx)callback with:name,label,adapter_factory,check_fn,validate_config,is_connected,required_env,install_hint,setup_fn,allowed_users_env,allow_all_env,max_message_length,emoji,platform_hint.Core code touches (justified)
After auditing against Teams' PR (which touches 0 core files outside its plugin dir), I kept 4 small core touches:
gateway/config.py— Platform enum entry + populator block +_PLATFORM_CONNECTED_CHECKERSentry. The enum entry avoids touching ~15 call sites that usePlatform.GOOGLE_CHATattribute access; the populator wiresGOOGLE_CHAT_HOME_CHANNELinto config.platforms which the cron scheduler reads; the checker satisfiestest_platform_connected_checkers.gateway/run.py— one block in_is_user_authorizedthat addssource.user_id_alt(email) to the allowlist match set whensource.platform == Platform.GOOGLE_CHAT. Operators configureGOOGLE_CHAT_ALLOWED_USERSwith email addresses butsource.user_idisusers/{id}(Chat resource name); without this bridge email-based allowlists silently fail.cron/scheduler.py—_KNOWN_DELIVERY_PLATFORMSand_HOME_TARGET_ENV_VARSentries so cron jobs can deliver to a configured Google Chat space. (Teams'TEAMS_HOME_CHANNELis documented but not wired through here — generic plugin home_channel support would be a worthwhile follow-up that would let both platforms drop these entries.)pyproject.toml— new[google_chat]optional-dependencies extra (google-cloud-pubsub, google-api-python-client, google-auth-*).Everything else lives in the plugin: 685+638 LOC adapter+oauth, 2132 LOC tests, 370 LOC user guide.
Test Coverage
130 tests in
tests/gateway/test_google_chat.py, all passing. Coverage spans:media.upload+messages.createwithattachmentDataRef)_sanitize_email, scoped 401 eviction, slash command per-sender writes, scoped revokeFull repo
tests/gateway/shows only pre-existing dingtalk/whatsapp/approve_deny failures unrelated to this PR (missing optional SDKs).Manual validation
Deployed and running in production on a single-user mac-mini for ~2 weeks. Three real users have authorized via
/setup-filesand exchanged native file attachments end-to-end. Pub/Sub flow, threading, attachment SSRF guards, and per-user OAuth refresh all validated under live traffic.Distribution
No new artifact — this ships inside the existing hermes-agent package. Users opt-in via
pip install 'hermes-agent[google_chat]'and follow the 8-step GCP setup walkthrough in website/docs/user-guide/messaging/google_chat.md.Test plan
pytest tests/gateway/test_google_chat.py— 130/130 passpytest tests/gateway/test_platform_connected_checkers.py— passes (the new entry satisfies the contract)pytest tests/agent/test_prompt_builder.py— passes (no regression from removing the in-tree platform_hint)/setup-files, file delivery works in their DMs🤖 Generated with Claude Code