Add an interactive Raft setup flow for hermes gateway setup. The wizard follows the existing platform adapter setup pattern, persists RAFT_PROFILE to the Hermes env file, preserves an existing profile when the user declines reconfiguration, and registers the flow via setup_fn.
Add focused Raft adapter coverage for saving RAFT_PROFILE, keeping an existing profile, and registering setup_fn.
Signed-off-by: skyzh <skyzh@mail.build>
Signed-off-by: HaoHao <HaoHao@mail.build>
Matrix outbound image downloads validated only the final URL after
following redirects, so a public URL that 302-redirects to loopback /
private-network / cloud-metadata endpoints had already connected to the
unsafe hop before the check ran.
Re-validate every redirect hop before following it:
- aiohttp path resolves redirects manually with allow_redirects=False,
validating each Location via is_safe_url (aiohttp can't use the httpx
response event hook).
- httpx fallback installs the shared _ssrf_redirect_guard event hook.
Regression tests cover per-hop blocking of an unsafe redirect, following
a safe redirect chain, and httpx guard wiring.
An invite to a room with no remaining members surfaces as "no servers
in the room have been provided" or "room not found" on join. The pending
invite was never cleared, so every gateway startup re-attempted the join
and re-emitted the warning indefinitely.
Detect that specific failure mode by narrow error-message match and call
leave_room to decline the invite; transient/network errors leave the
invite untouched for the next sync. Adds 5 tests.
Reimplements the matrix portion of #33953 onto the current plugin adapter
(gateway/platforms/matrix.py was relocated to
plugins/platforms/matrix/adapter.py since the PR was opened). The two
gateway/status.py fixes from that PR (wrapper-subcommand rejection,
psutil start-time fallback) already landed on main independently.
Reported by @Bougey; original patch authored by @KiraKatana.
Follow-up to the #55780 dead-target not_found blast-radius fix (merged in
#56225). classify_send_error and is_chat_level_not_found each built their own
lowercased error blob, but divergently: classify_send_error appended the
exception CLASS NAME while is_chat_level_not_found did not. A caller passing
exc= to both could get inconsistent answers on the same failure.
- Extract _error_blob(exc, error_text) as the single source of truth both
classifiers use (str(exc) when non-empty + class name; no stray leading
space).
- Align is_chat_level_not_found's signature to (exc, error_text), matching
classify_send_error, removing the swapped-positional footgun; update the
sole caller and the three tests to keyword form.
- Add a regression guard asserting _error_blob keeps the class name.
Surfaced by the hermes-pr-review Phase 2c structured review of #56225.
#55115 added the dead-target registry so confirmed-dead delivery targets are
short-circuited. Its documented scope (gateway/dead_targets.py) is deliberately
narrow: only *whole-chat* deaths -- the `forbidden` and chat-level `not_found`
(`chat not found`) kinds -- should be recorded; "Thread/topic-level not_found is
NOT recorded here ... a deleted topic does not mean the parent chat is dead."
But the implementation doesn't honor that scope. classify_send_error collapses
chat-level "chat not found" AND thread/message-level not_found ("thread not
found", "topic_deleted", "message_id_invalid", "message to edit/reply not
found") into one "not_found" kind, _DEAD_ERROR_KINDS contains "not_found"
wholesale, and deliver()'s except marks the PARENT chat_id dead. So a single
deleted Telegram topic or edited-away message permanently marks the entire chat
(and every future scheduled / cron / agent delivery to it) dead -- silently. The
adapter self-heal the docstring relies on only covers the non-private-group
thread retry; named-DM-topic and message-level failures propagate to deliver()'s
except and wrongly kill the whole chat.
Add is_chat_level_not_found() (factoring the not_found substrings into chat-level
vs sub-chat-level constants) and gate the delivery dead-path: a "not_found" only
marks the target dead when it is chat-level. classify_send_error's public
contract is unchanged (still returns "not_found" for every shape); only the
mark_dead decision is refined, restoring the registry's documented scope.
Cross-platform: telegram/slack/discord delivery all flow through
classify_send_error -> mark_dead. Adds regression tests through the real
deliver() path plus helper/classifier units.
The three salvaged PRs (#46647, #54583, #55013) were authored against a
tree where _refresh_agent_cache_message_count was sync and _session_db was
the raw SessionDB. On current main the helper is async and awaits the
AsyncSessionDB facade, and _run_agent was split into _run_agent_inner.
- Wrap test _session_db in AsyncSessionDB so the awaited get_session works
- Make refresh-calling tests async + await the helper
- Point the placement-guard test at _run_agent_inner (recursion lives there
post-mixin-extraction)
- Relocated production call sites now correctly await the async helper
The cross-process coherence guard (#45966) compares the session's
on-disk message_count against the snapshot stored next to the cached
agent, and rebuilds the agent on a mismatch. The guard is correct
when the cache snapshot and the live count both refer to the same
DB row. But the agent cache is keyed by session_key, which can
group multiple conversation threads (different session_ids) under
the same key — and the message_count values belong to DIFFERENT
DB rows.
When the user switches from session A to session B under the same
session_key, the cache hit returns A's cached agent. The guard then
compares A's snapshot count (A.message_count) against B's live count
(B.message_count) — they are NEVER equal because they track
different conversations — and invalidates the cache. Every session
switch busts the prompt cache and forces a fresh agent build. The
post-turn re-baseline (#46237) made it worse: it reads the live
count from the CURRENT session_entry.session_id, so each switch
overwrites the original snapshot with the new session's count,
causing the very next switch BACK to the original session to fire
the guard again.
This is the bug from #54947 (P0, sweeper:risk-session-state,
sweeper:risk-caching).
Fix:
* Record the snapshot's session_id alongside the message_count in
the cache tuple: (agent, sig, mc, session_id) — a 4-tuple. The
cache build at the AIAgent construction site stores the active
session_id.
* The cache-hit guard skips the cross-process count comparison
when the active session_id differs from the snapshot's
session_id — the comparison is meaningless across different DB
rows, so the agent is REUSED without invalidation. The cross-
process guard still fires when the session_id matches and the
live count differs (genuine cross-process write on the SAME
session).
* _refresh_agent_cache_message_count checks the snapshot's
session_id: when it differs from the current session_id, the
snapshot is intentionally left untouched (overwriting it would
corrupt the original conversation's baseline and cause the
switch-back to fire the guard). The legacy 3-tuple shape (no
session_id) is still re-baselined as before.
* Backward-compat:
- 2-tuple (agent, sig) — unchanged, opts out of the guard.
- 3-tuple (agent, sig, mc) — unchanged behavior, standard
cross-process check.
- pending sentinel — unchanged, untouched by re-baseline.
- new 4-tuple (agent, sig, mc, session_id) — full session_id-
aware guard with skip on mismatch.
Tests:
* tests/gateway/test_session_id_cache_coherence.py — 7 tests
covering L1-L5 from LAYERS.md:
- L1 session_id switch must REUSE
- L2 cache tuple records snapshot's session_id
- L3 re-baseline skips when session_id differs
- L4 same-session_id turns still re-baseline (#46237 holds)
- L5 legacy 2-tuples and pending sentinels untouched
- legacy 3-tuple (no session_id) still guarded (#45966 holds)
- 3-tuple transitions to 3-tuple (not 4-tuple) on re-baseline
No regressions in 70 existing tests in test_agent_cache.py or 137
related session tests. Co-authored with #52197 (deferred cleanup
of evicted agents); both fixes compose cleanly.
The cross-process cache-coherence guard (#45966) compares a session's
on-disk message_count against a snapshot stored next to the cached agent,
rebuilding the agent on a mismatch so a foreign writer (e.g. the dashboard
backend) can't leave the in-memory transcript stale.
On a fresh gateway conversation the post-turn re-baseline
(_refresh_agent_cache_message_count) ran BEFORE the first-turn `session_meta`
marker row was appended to the transcript. That append goes through
append_to_transcript -> append_message, which increments message_count
unconditionally. So the snapshot was left exactly one short of the live
count, and on turn 2 of every fresh conversation the guard mistook this
process's own session_meta write for a foreign write, evicting and rebuilding
the cached agent — silently busting the per-conversation prompt cache the
cache exists to protect.
Move the re-baseline to after the turn's full transcript persistence block
(including the session_meta append and the compression session_id swap). The
snapshot now matches the live count, so the guard fires only on genuinely
foreign writes. This also makes the call honor its own documented contract of
using the compaction-updated session_id.
Adds a regression test that drives the real _handle_message_with_agent
against a real SessionDB and asserts the invariant: after a fresh first turn,
snapshot == live message_count, so the next turn's guard reuses the cached
agent. Fails before this change, passes after.
When two features register a post-delivery callback for the same session
(e.g. background-review release + /goal continuation), the second
registration is composed with the first via a `_chained` wrapper. That
wrapper was `def _chained()` — a sync function calling each callback
via `_prev()` / `_new()` and discarding the return value.
For sync callbacks that's fine. For async callbacks (such as the
`_deliver()` coroutine the /goal feature registers to inject the
continuation prompt) the returned coroutine was silently dropped:
RuntimeWarning: coroutine '_deliver' was never awaited.
Outer invoker in `_handle_message` already checks
`inspect.isawaitable(_post_result)` and awaits — but only sees the
wrapper's return value, which was `None`.
Fix: make `_chained` async, iterate over chained callbacks, await any
that return an awaitable. Outer invoker already handles awaitable
wrappers, so no other change is needed.
Tested:
* Added two regression tests in test_post_delivery_callback_chaining.py
covering an async callback chained behind sync (and vice versa).
* Updated existing chaining tests + test_run_cleanup_progress.py to
await the popped callback when it's awaitable.
* 62 tests pass across the touched suites.
Live-validated on Discord: /goal continuations now arrive after the
first turn's response is delivered (previously silent).
Refs: NousResearch/hermes-agent#31922
A delayed fatal-error notification from an adapter instance that has
already been replaced by a successful reconnect (a different adapter
object now owns the platform slot) was still processed: it overwrote
the platform's runtime status back to retrying/fatal and could
re-queue an already-healthy platform for reconnection.
Snapshot the current owner of the platform slot at the top of
_handle_adapter_fatal_error and bail out before any side effect when
it belongs to a different, already-installed adapter.
_handle_polling_network_error's chained retry never updated
self._polling_error_task, so the reentrancy guard shared with the
heartbeat loop and the pending-updates probe went stale mid-recovery,
letting more than one recovery attempt run concurrently against the
same adapter. Combined with a TOCTOU window in
_handle_adapter_fatal_error (the adapter was only removed from
self.adapters in a finally block after awaiting disconnect()), two
concurrent fatal notifications for the same adapter could both pass
the "still installed" check and call disconnect() twice, which is
where the reported "'NoneType' object has no attribute 'updater'"
originates once self._app is cleared by the first call.
- Reassign the chained retry task to self._polling_error_task so the
guard reflects an in-flight recovery.
- Capture self._app in a local variable across the stop/start_polling
sequence instead of re-reading self._app between awaits.
- Claim (pop) the adapter from self.adapters before awaiting
disconnect() in _handle_adapter_fatal_error, not after, closing the
TOCTOU window for a concurrent notification on the same adapter.
When /compress rotates the session, the handler repointed the live
session entry onto the new (empty) continuation session_id and _save()d
that BEFORE writing the compressed transcript — and rewrite_transcript
swallowed DB write failures at DEBUG. A transient write failure (SQLite
lock under concurrent writes, ENOSPC, disk/IO error) left the session
pointing at an empty id while the handler still reported a cheerful
'Compressed: N → M' success. The active conversation vanished from view.
- gateway/session.py: rewrite_transcript now returns bool (True on write
success or no-DB, False on canonical write failure). /retry, /undo, and
yuanbao recall ignore the result, so their behavior is unchanged.
- gateway/slash_commands.py: _handle_compress_command persists the
compressed transcript FIRST and treats a write failure as fatal (raises
into the outer handler's 'compress failed' banner). Only repoints +
_save()s the session on a successful write. Widened beyond the original
rotation case to also cover in-place compaction (#38763): a failed
in-place write would otherwise leave the DB untouched while still
reporting success.
- tests: regression tests for both the rotation and in-place write-failure
paths — assert a failure banner, unchanged session_id, and no _save().
Co-authored-by: Hermes Agent <agent@nousresearch.com>
When the primary provider's auth fails (expired token / 429 quota cap),
_resolve_runtime_agent_kwargs() falls through to the fallback provider
chain, whose runtime dict carries its own 'model' key. api_server's
_create_agent then did AIAgent(model=model, **runtime_kwargs), colliding
on 'model' and 500ing every /v1/chat/completions request while a fallback
was active. Pop the runtime model and let it override the config model,
mirroring the native gateway path (_resolve_session_agent_runtime).
Salvaged from #35716 by @ryo-solo (earliest submitter); the PR's second
half (Mistral reasoning_content strip) is already handled on main and
dropped.
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
The forum-topic skill-binding lookup assumed config.extra['group_topics']
was always a list of {chat_id, topics} entries. When an operator writes the
natural mapping shape ({"-100...": [...]}), iterating yields string keys and
chat_entry.get(...) raises AttributeError, breaking dispatch for that group.
Normalize both shapes to a common iterator and guard non-dict/non-list
entries so malformed config falls through cleanly instead of crashing.
mcp-tokens/ holds live MCP OAuth access tokens (<server>.json) and
dynamically-registered OAuth client credentials (<server>.client.json),
layout per tools/mcp_oauth.py. This is the same credential class as
auth.json/credentials/, which _media_delivery_denied_paths() already
blocks. The write side already denies this dir (file_tools
_check_sensitive_path), but the media-delivery (read/exfil) side did
not, leaving an unpaired half-door.
Without it, a prompt-injection MEDIA: tag emitting
~/.hermes/mcp-tokens/<server>.json would, in default (non-strict)
mode, pass the denylist and exfiltrate a live OAuth bearer token to
the same untrusted channel. Sibling follow-up to commit 4ec0adebe
(config.yaml media-delivery denylist).
mcp-tokens is a directory and _path_under_denied_prefix already does
containment matching, so the whole subtree (.json/.client.json/
.meta.json) is denied, mirroring credentials/.
The outbound chat resolver in BlueBubblesAdapter._resolve_chat_guid()
matched on participant addresses after the exact chatIdentifier check,
which let an outbound DM reply leak into a group thread when the same
contact existed in both a 1:1 DM and a group chat: if the group chat
was returned earlier by /api/v1/chat/query and the DM's
chatIdentifier differed from the bare address, the participant match
on the group fired first and returned the group GUID. That GUID was
then cached under the bare address, so every subsequent reply went to
the wrong chat.
Restrict resolution to:
1. raw GUID passthrough
2. exact chatIdentifier / identifier match
When no exact match exists the resolver now returns None and the
caller already handles that path safely: send() creates a fresh DM via
_create_chat_for_handle for address-shaped targets, and
_send_attachment fails with a clear "chat not found" error rather than
guessing into a group.
Adds regression tests under TestBlueBubblesGuidResolution covering:
- exact chatIdentifier match still resolves to the DM
- participant-only presence does not resolve to the group
- the DM is chosen even when the group is returned first
- unresolved targets are not cached (no stale-None and no stale-group)
Fixes#24157.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test_no_dedup_seed_when_thread_creation_fails asserted the agent still ran
inline when auto-thread creation failed — the pre-#20243 silent-fallback
behavior. Flip that to assert_not_awaited() to match the new fail-closed
contract; the test's actual contract (phantom thread id must not leak into
the dedup cache on failure) is unchanged. Give the fake channel a send mock
so the failure-notice path runs cleanly.
When discord.auto_thread is enabled and a top-level server-channel message
should be routed to a new thread, a transient thread-create failure (e.g.
Cannot connect to host discord.com:443) returned None and _handle_message
fell through to an inline parent-channel reply — dumping a new task into a
shared channel and breaking thread-first workflows.
- _auto_create_thread retries the primary + seed-message paths once after a
750ms backoff for transient connect errors.
- _handle_message treats None as a hard failure: posts a short visible notice
in the parent channel and returns without invoking the agent. The notify
send is wrapped so a secondary connect error can't raise.
Fixes#20243
Group gating (_should_process_message) read the raw message_thread_id,
while event routing (_build_message_event) normalized it. A plain
non-forum group reply's message_thread_id is a reply-UI anchor, not a
topic, so an anchor id matching an ignored_threads entry wrongly
dropped the message, and the anchor was treated as a routable topic
under allowed_topics.
Extract _effective_message_thread_id and route both gating and
event-building through it, so gating and session routing agree on one
normalized value: real topic/forum messages keep their thread id, reply
anchors are dropped, and forum General-topic messages normalize to the
General-topic id.
When .restart_last_processed.json goes missing, a redelivered /restart from
Telegram polling can no longer be caught by the update_id comparison, so it
re-restarts the gateway forever (issue #18528, reported by @dontcallmejames
who hit it in production — gateway restarting every ~2min, zero messages
processed).
Fallback: on marker-missing, suppress the /restart only when we can confirm
we just came out of a restart cycle (_booted_from_restart, captured at startup
from .restart_notify.json before it is unlinked) AND the process is still
within a 60s post-boot window. Consumed one-shot. This closes the loop without
swallowing a genuine first /restart on a fresh boot — the flaw in the original
bare-uptime approach.
Credit to @dontcallmejames for the diagnosis and original patch.
Replace the interim monospace table fallback with Slack's native `table`
block (rows of rich_text cells). Addresses the core ask in #18918.
- _table_block(): builds type:"table" with rich_text cells, so inline
formatting (bold, links, code) renders inside cells.
- Column alignment parsed from the markdown separator row (:---, :-:, --:)
into column_settings (left = default/null-skip, center/right emitted).
- Escaped pipes (\\|) are not treated as column separators.
- Respects Slack's table limits (100 rows / 20 cols / 10k aggregate chars);
oversized or unparseable tables gracefully fall back to aligned monospace
(rich_text_preformatted), so a big table never breaks the message.
Docs (EN + zh-Hans) updated to describe native tables + the fallback.
Tests: native table shape, alignment->column_settings, inline-formatted
cells, oversized/too-wide monospace fallback, escaped-pipe cell. Prove-
failed against a stubbed _table_block (native-table tests fail, fallback
tests stay green). All existing Slack tests still pass.
Add platforms.slack.extra.rich_blocks (default off). When enabled, the
final agent message is sent as Slack Block Kit blocks — section headers,
dividers, and true nested lists via rich_text — instead of flat mrkdwn.
- New plugins/platforms/slack/block_kit.py: pure markdown->blocks renderer
(headers, dividers, nested ordered/bullet lists, blockquotes, fenced code;
pipe-tables as aligned monospace since Block Kit has no robust table block).
Enforces Slack's 50-block / 3000-char section limits and returns None to
fall back to plain text on empty/oversized/unexpected input. Never raises.
- adapter.send(): render blocks on the single-chunk primary message; a
text= fallback is ALWAYS sent alongside (notifications/accessibility).
- adapter.edit_message(): blocks only on finalize=True, so intermediate
streaming edits stay plain mrkdwn (no per-flush block re-derivation).
- Docs (EN + zh-Hans) + config example. Send-side only: no app reinstall.
Tests: pure-renderer unit suite + adapter integration suite (blocks present
when on, plain text when off, text fallback always set, finalize gating,
multi-chunk fallback). Prove-failed against a stubbed renderer.
The agent emits a bare control marker (NO_REPLY / [SILENT] / …) when it
intentionally chooses not to reply. The gateway's whole-response filter
(is_intentional_silence_agent_result) suppresses this on the non-streaming
delivery path, but the streaming path (GatewayStreamConsumer) had no silence
awareness: it edited the raw marker onto the screen delta-by-delta and
finalized it BEFORE the whole-response filter could run. On any
streaming-capable adapter (Slack, Telegram, Discord, …) users saw a literal
'NO_REPLY' message leak into chat.
Fix (contained in the stream consumer + a shared predicate; no new config,
no platform-specific code):
- gateway/response_filters.py: add is_partial_silence_marker() — the
streaming counterpart to is_intentional_silence_response(), sharing the
same marker set and canonicalization so the two never drift.
- gateway/stream_consumer.py:
- Mid-stream hold-back: defer edits while the accumulated buffer is still a
prefix of a silence marker, so a partial marker never flashes on an
interval tick.
- On stream end (got_done): if the final buffer is exactly a marker, retract
any preview already shown (best-effort delete_message, reusing the
_try_fresh_final cleanup path) and leave the delivery flags False so the
gateway's own filter turns the marker into '' and no fallback send fires.
Substantive prose that merely mentions a marker is still delivered normally.
Tests: tests/gateway/test_stream_consumer_silence.py — predicate truth table
+ end-to-end run() suppression (single-shot + token-by-token), preview
retraction, no-delete-support best-effort, [SILENT] parity, and
prose-passthrough. Prove-fail verified by reverting only the consumer change
(the 4 behavioral tests fail: 'NO_REPLY'/'[SILENT]' leaks).
The gateway HALF of the D-Q2.5c cleanup (connector half: gateway-gateway #92).
Scope is STRICTLY the relay adapter (gateway/relay/) — session.py and every
native platform adapter are untouched (SessionSource.guild_id remains for their
use; it is NOT relay-only).
Within gateway/relay/, drop the D-Q2.5 wire dual-write/dual-read alias AND
genericize all platform-specific (Discord "guild") scope terminology:
- ws_transport._event_from_wire: read scope_id only (drop the ?? guild_id fallback).
- adapter._with_scope: emit scope_id only on outbound metadata (drop the
guild_id dual-write); genericize the "GUILD reply" docstring to "SCOPED reply".
- adapter._capture_scope: read source.scope_id only; rename the local `guild`
var to `scope`; genericize the docstring + the _scope_by_chat/_dm_user_by_chat
field comments ("guild_id (Discord)" -> "scope_id (server/workspace scope)").
- __init__.relay_route_keys docstring: "guild_ids" -> "scope_ids".
- The ONE real Discord `guild_id` kept: the raw inbound interaction payload
field (payload.get("guild_id")), which is Discord's own wire field, mapped
straight into the generic scope_id slot — unchanged.
Contract doc (docs/relay-connector-contract.md): reframe the `guild_id` row as
a legacy alias the connector no longer reads (session.py's agent-wide to_dict()
still emits it for non-relay persistence, so it stays documented + wire-present
but ignored) — accurate, and keeps the to_dict()-vs-doc conformance test green.
Tests (relay only): migrate the wire-key writes + assertions guild_id -> scope_id
across test_relay_adapter / _ws_transport / _passthrough / _roundtrip /
_roundtrip_telegram / _multiplatform; keep raw Discord `type:2` interaction
payloads' guild_id (real Discord field) and the conformance test's guild_id
parametrize (validates the kept legacy field stays wire-reachable).
Gate: 156 relay tests pass, ruff clean. Cross-repo E2E — all 14 drivers pass
BOTH ways: connector#92 (scope_id-only) x agent-main (still dual-reads) AND
connector#92 x this worktree (scope_id-only). Deploy-order-safe either way.
Mitigates indirect prompt injection (CWE-863) in Slack thread context.
When the bot is mentioned mid-thread for the first time, _fetch_thread_context
pulls the full thread via conversations.replies and prepends every reply to
the LLM prompt. Replies from senders not on the allowlist were rendered
identically to authorised senders, letting a third party in a shared channel
inject instructions the model might act on when answering the next authorised
message.
- BasePlatformAdapter.set_authorization_check / _is_sender_authorized, registered
by GatewayRunner._make_adapter_auth_check() with a closure over the existing
_is_user_authorized chain (platform/global/group allowlists, allow-all flags,
pairing store all stay the single source of truth — no env-var re-parsing).
- Tags non-bot thread messages whose sender fails the auth check with an
[unverified] prefix; strengthens the header with soft guidance only when at
least one unverified message is present, so setups without an allowlist see
no behaviour change.
- Wired into all three adapter-init sites in run.py (start, reconnect watcher,
restart) so the reconnect path is covered too.
Softened wording: adapted from the original [untrusted] tag to [unverified]
and non-accusatory header framing — the label reflects allowlist status, not
a judgment about the person. Adapter relocated to plugins/platforms/slack/
since the PR was authored.
Salvaged from #17059.
Buffered text/photo/media-group flushes and the polling-error recovery
task sit behind an asyncio.sleep(). On disconnect they kept running and
dispatched handle_message() into a torn-down session, producing stale or
duplicate deliveries. disconnect() only cancelled media-group and photo
batch tasks — text batches and the polling-error task leaked.
Set a _drop_delayed_deliveries flag from _mark_disconnected/_set_fatal_error
(cleared by _mark_connected) and check it in all enqueue+flush paths so a
flush that wins the race against teardown drops instead of dispatching.
_cancel_pending_delivery_tasks() now cancels+clears all four task maps,
skipping the current task. Media-group flush finally-block guarded so a
cancelled stale flush cannot erase a replacement task handle.
/queue rebuilt the queued MessageEvent with only text/type/source/
message_id/channel_prompt, silently dropping any photo, document, voice,
or reply context attached to the command. The deferred turn then ran with
the attachment lost. Carry the full payload through, and accept a /queue
that has media but no prompt text (e.g. "/queue" as an image caption).
Salvaged from #13913 by @ypwcharles — the gateway busy-session/queue
infrastructure was rewritten since that PR (Telegram moved to
plugins/platforms/, /queue now uses the FIFO chain), so the media fix is
reimplemented against the current handler; the PR's batching and
busy-bypass changes targeted code paths that no longer exist.
Co-authored-by: ypwcharles <92324143+ypwcharles@users.noreply.github.com>
Two independent fixes salvaged from #12811 (closing it; one of its three
bundled fixes — Discord free_response — is already on main).
Anthropic max_tokens (#12790): the chat-completions max_tokens fallback only
fired for OpenRouter/Nous URLs, so any other proxy serving a Claude model
(AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) shipped requests
with no max_tokens and inherited the proxy's low default (Bedrock: 4096),
exhausting on thinking + large tool calls. Changed the gate in
chat_completion_helpers.build_api_kwargs from URL-gated to model-gated:
fires whenever the model matches an _ANTHROPIC_OUTPUT_LIMITS key. This also
fixes a latent miss — the old 'claude' substring gate skipped MiniMax and
Qwen3 even on OpenRouter. Remains a last-resort fallback (build_kwargs only
applies it after ephemeral/user/profile max_tokens), so it never overrides
an explicit value, and only touches the chat-completions transport (native
Anthropic Messages API is a separate path).
Feishu channel_prompt (#12805): the Feishu adapter never resolved
channel_prompts config, unlike Discord/Slack, so per-channel role prompts
were silently ignored. Added _resolve_channel_prompt() (delegating to the
shared gateway.platforms.base.resolve_channel_prompt) and wired it into all
three MessageEvent construction sites — inbound message, reaction routing,
and card-action routing.
Tests: tests/gateway/test_feishu_channel_prompts.py (6 cases) covering exact
match, parent-thread fallback, no-match, missing-config safety, and event
propagation.
Some legitimate @bot pings were dropped because the mention gates relied on
message.mentions alone, which does not always populate raw <@ID> / <@!ID>
forms (mobile, edited, relayed messages). A bare @bot with no other text
could also spawn a fake empty-text turn.
- add _self_is_explicitly_mentioned() / _raw_mentioned_user_ids() helpers that
treat the bot as mentioned via resolved mentions OR raw content forms
- use them at the allow_bots=mentions gate, multi-agent bot filtering, the
mention-strip/mention_prefix step, and the require_mention gate
- drop bare mention-only pings (no text, no media, no injection, no backfill
context) instead of injecting a placeholder empty turn
Co-authored-by: Teknium <teknium1@gmail.com>
The polling heartbeat's pending-update probe treated a stopped updater
(running=False) as "someone else's job" and silently reset its counter,
so a long-poll task that disappears with no reconnect in flight was never
recovered. get_me() on the general request path stays healthy, so neither
PTB's error_callback nor the connectivity probe ever fires — the gateway
keeps running but stops receiving messages indefinitely (#55769).
Detect the stopped-updater case directly in _probe_pending_updates and feed
it into the existing _handle_polling_network_error ladder, debounced over two
consecutive probes so a just-starting updater or the brief stop()->start_polling()
window of an in-flight reconnect never trips it.
The inbound-media validator _is_allowed_bridge_path() checked against
IMAGE_CACHE_DIR / AUDIO_CACHE_DIR / VIDEO_CACHE_DIR / DOCUMENT_CACHE_DIR
value-imported at module load. After the base.py cache-dir getters became
per-call resolvers, the bridge writes media into the active profile's cache
while the validator still matched the frozen launch-profile constants — so
media was rejected under a profile override (multi-profile gateway).
Resolve the cache roots per-call via the get_*_cache_dir() getters and drop
the now-unused frozen value-imports. Caught by automated review on #55867.
The reachability claim that single-process multi-profile leakage is desktop-
only is incomplete. gateway/run.py:_profile_runtime_scope shows a SECOND such
runtime: the multiplexed gateway (gateway.multiplex_profiles) serves every
profile from one process, scoping each inbound turn with the same
set_hermes_home_override ContextVar the desktop uses (and the /p/<profile>/
URL prefix). The M1 (import-time path globals) and M2 (thread/executor
context) leaks are reachable there identically.
- tests/gateway/test_multiplex_credential_isolation.py: add a class driving the
skills-dir + cache-dir resolvers and a propagated worker thread under the
real _profile_runtime_scope, asserting each resolves the active profile. Sits
beside the existing credential-isolation proofs for the same topology.
- Correct the inline comments in model_tools/run_agent/async_delegation/
rich_sent_store to name both runtimes (desktop tui_gateway AND the
multiplexed gateway) instead of implying desktop is the only surface.
(ACP runs one agent per subprocess and the kanban dispatcher Popens
'hermes -p <profile>' children, so neither is an in-process multi-profile
surface; desktop + multiplexed gateway are the two confirmed ones.)
Adds gateway.platform_connect_timeout (default 30s) to DEFAULT_CONFIG and
bridges it to the internal HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT env var
at gateway startup, following the existing gateway_timeout config->env
pattern. The env var remains the manual-override escape hatch and wins if
set explicitly; otherwise config.yaml supplies the value. This closes the
issue's documentation/config-surface request (#19776 suggestion 2) on top
of the adapter ready-wait fix, so users no longer need an undocumented env
var to raise the Discord connect timeout.
Refs #19776
The extension-less MEDIA delivery guards short-circuited on
"MEDIA: not in text and [[audio_as_voice]] not in text", so a
response carrying only [[as_document]] (an image-only reply requesting
unmodified document delivery) leaked the directive as visible text.
Add [[as_document]] to both guard conditions (_strip_media_tag_directives
and strip_media_directives_for_display) and cover it with a regression
test.
Follow-up to liuhao1024's #46924. Route plain-text approval replies
through the canonical /approve and /deny handlers (resolve thread, resume
typing, return localized confirmation) and deliver that confirmation back
to the user — previously a plain 'yes' resolved silently. Synthesize a
literal '/'-prefixed command so get_command_args() parses always/session
modifiers on every platform (is_command() only recognizes '/'). Add E2E
tests covering approve/deny/always/session vocab plus the no-pending and
unrelated-text fall-through cases.
The STT-failure enrichment templates injected setup instructions —
"no STT provider is configured", "a direct message has already been
sent", and a "hermes-agent-setup" skill mention — into the LLM-visible
prompt. That text persists in conversation history, so after one STT
failure the model kept volunteering Whisper/Vosk setup advice on every
later voice turn, even after transcription started working (observed in
prod on gpt-5-nano). The gateway also fired a hardcoded English notice
via _stt_adapter.send(), producing a second, wrong-language reply that
TTS then spoke aloud.
- Neutralize all enrichment templates: success passes the transcript
through as a plain quoted line; every failure branch emits a single
[voice message could not be transcribed] marker.
- Move the operator-facing failure cause to logger.info so it stays
diagnosable in container logs without leaking into the prompt.
- Remove the hardcoded English _stt_adapter.send() notice; the LLM now
produces one coherent reply in the user's language.
- Update the gateway STT tests to assert the neutral contract.
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
Two independent bugs evicted the cached gateway AIAgent on every turn,
preventing the prompt cache from ever warming:
1. Model normalization mismatch: the post-run fallback-eviction check
compared _agent.model (stripped in AIAgent.__init__) against the raw
_resolve_gateway_model() config string. For vendor-prefixed config on
native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro')
this was always unequal, so the agent was evicted after every
successful run. Normalize _cfg_model the same way (skip aggregators).
2. Discord triggering message_id leaked into the cached system prompt via
build_session_context_prompt()'s Discord IDs block. message_id changes
every turn, so the agent-cache signature (computed from the ephemeral
prompt) changed every Discord turn -> rebuild every message. The id is
now injected per-turn into the user message (where per-turn content
belongs and does not touch the cache signature); the cached IDs block
carries a static pointer to it, preserving reply/react/pin via the
discord tools.
Adapted from #28846. Bug #1 fix is the contributor's; bug #2 reworked to
be non-destructive (keeps the triggering-id capability instead of deleting
it). Redundant auto-reset eviction (already on main via #9893/#48031) and
the wrong-premise reset_context_note plumbing from the original PR were
dropped.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
DiscordAdapter.edit_message clipped any formatted payload over the 2,000-char
cap to [:1997]+"..." and returned success=True, so the stream consumer
believed the full reply landed and stopped — the user lost everything past the
boundary and perceived the agent as quitting mid-task.
edit_message is now overflow-aware, mirroring Telegram's proven contract:
- finalize=True: split-and-deliver via _edit_overflow_split — edit chunk 1 in
place, send chunks 2..N as reply-threaded continuations, return the last
visible id in message_id plus continuation_message_ids so the stream
consumer keeps editing the most recent chunk and can clean them all up.
- finalize=False (mid-stream): truncate a one-message preview in place, never
split. A mid-stream split moves the edit target to a continuation and the
next accumulated-token tick re-splits, looping forever (the Telegram #48648
lesson the original port predated).
- Reactive 50035 '2000 or fewer in length' on edit runs the same branch logic.
- Partial continuation failure still reports success with a partial_overflow
raw_response so the consumer retries the tail instead of marking a clipped
reply complete.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com>
GatewayStreamConsumer.run() processed queued deltas in an infinite loop
with no check on whether the session was still current. On /new or /stop
mid-stream, the consumer kept editing and delivering stale response
fragments alongside the 'Session reset!' ack.
PR #11016 (b7bdf32d) fixed the runner side via sentinel promotion/release
but left the stream consumer unguarded. Every other async callback in
run.py already bails via _run_still_current(); the stream consumer was
the only one missing it.
- stream_consumer.py: optional run_still_current callback, checked at the
top of the run() loop; returns early when the session is stale.
- run.py: pass the existing _run_still_current closure at both call sites
(proxy path and agent path).
- tests: TestRunStillCurrentGuard — immediate staleness, mid-stream
staleness, always-current, no-callback default, pending-finish.
Co-authored-by: jasonQin6 <39369769+jasonQin6@users.noreply.github.com>
WhatsAppAdapter lives under plugins/platforms/whatsapp/adapter.py on
current upstream; the owner-forward test still imported the removed
gateway.platforms.whatsapp module.
When WHATSAPP_FORWARD_OWNER_MESSAGES is enabled and the bridge marks an
inbound message with fromOwner=true, also prefix MessageEvent.text with
"[owner reply] " at construction time. This makes the disambiguation
survive any downstream plugin failure (e.g. handover-rule errors that
bypass silent_ingest), so transcripts never misattribute owner-typed
text to the customer.
Idempotent: re-applies are guarded so a future producer that pre-tags
text won't be double-prefixed.
In `WHATSAPP_MODE=bot` the bridge currently drops every fromMe inbound
message — they are all assumed to be echoes of our own /send calls.
That makes it impossible for plugins / agents to detect when a human
owner has typed directly into a customer chat from the same WhatsApp
Business account (e.g. via a linked phone or WhatsApp Web).
This adds an opt-in `WHATSAPP_FORWARD_OWNER_MESSAGES` env var. When
true, the bridge classifies fromMe inbound by looking up `key.id` in a
bounded LRU of recently-sent message IDs (the existing 50-entry echo
suppressor, bumped to 512 and extracted to a testable
`outbound_ids.js` helper). Hits in the LRU are still dropped (echoes);
misses are forwarded to the Python adapter with `fromOwner: true`.
The Python adapter lifts that flag onto
`MessageEvent.metadata["whatsapp_from_owner"]`. `metadata` is a new
free-form dict on the event so future per-platform signals don't each
need their own field. Default behaviour is unchanged: with the env
flag unset, bot mode still drops every fromMe message exactly as
before.
Use cases for downstream consumers:
- Implicit handover activation when the owner replies manually
- Sliding TTL on owner activity (keep an active session alive while
the owner is engaged)
- Audit trails of owner interventions
- Analytics on human-vs-bot reply ratios
Heuristic limitation (documented in code): the LRU is in-memory. After
a bridge restart, in-flight delivery receipts of pre-restart sends will
briefly look like owner-typed for a few seconds until the set is
repopulated. Persisting isn't worth the disk churn — downstream
consumers should treat the flag as best-effort.
Tests:
- tests/gateway/test_whatsapp_from_owner.py (new): adapter sets the
metadata flag iff the bridge payload has `fromOwner: true`; absent
otherwise.
- scripts/whatsapp-bridge/outbound_ids.test.mjs (new): LRU bounds,
eviction order, falsy-id handling.
Backwards compatibility: with the env flag unset, every code path is
identical to before. No existing deployment is affected.
The base BasePlatformAdapter implementations of send_voice, send_video,
send_document, and send_image_file forwarded their *_path argument
verbatim into the chat text (e.g. "🎬 Video: /home/.../hermes/cache/...").
Telegram, Discord, and Slack adapters all fall back to those base methods
when their native send raises — so a rejected video on Telegram surfaced
the host filesystem layout to the user instead of a useful message.
Replace the path-echo with a friendly notice, log the path for operator
diagnostics, and keep the user-supplied caption intact. The Slack adapter
had three identical sites that fell through to the same path-echo on its
own native upload failures; fix those too. send_document still surfaces
the caller-provided file_name (or the basename derived from it) since
that is the user-facing filename, not a host path.
Add regression tests asserting the *_path argument never appears in the
fallback content while caption text and explicit file_name still do.
The salvaged test double predated two main changes:
- start() now connects via _connect_adapter_with_timeout, which forwards
is_reconnect to adapter.connect(); the StartupRaceAdapter double didn't
accept the kwarg.
- stop() now awaits _finalize_shutdown_agents (async on main); the fixture
stubbed it as a plain MagicMock.
Accept is_reconnect in the double and use AsyncMock for the finalize stub.