Commit graph

1612 commits

Author SHA1 Message Date
briandevans
c279706d33 fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid
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>
2026-07-01 00:42:56 -07:00
hinotoi-agent
66325a7700 fix(api-server): scope run approvals by run id 2026-07-01 00:42:42 -07:00
teknium1
909330a61c test(discord): fix double-dispatch dedup test for fail-closed auto-thread
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.
2026-07-01 00:12:17 -07:00
0xsir0000
50a7dce6bd fix(discord): auto-thread failure must not silently fall back to inline reply
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
2026-07-01 00:12:17 -07:00
nocturnum91
cc1e4c32c0 fix(telegram): normalize thread id in group gating via shared helper
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.
2026-07-01 00:11:46 -07:00
Teknium
cdd553945e
fix(gateway): guard stale /restart redelivery when dedup marker is missing (#56107)
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.
2026-07-01 00:11:23 -07:00
Ben
7c7b489813 feat(slack): render markdown tables as native Block Kit table blocks
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.
2026-07-01 00:10:12 -07:00
Ben
b080b93ad8 feat(slack): opt-in Block Kit rendering for agent messages
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.
2026-07-01 00:10:12 -07:00
Ben
5f7deeba84 fix(gateway): suppress NO_REPLY/[SILENT] markers on the streaming path
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).
2026-06-30 23:37:04 -07:00
Ben Barclay
729bbb7a30
refactor(relay): purge platform-specific scope terminology from the relay adapter (D-Q2.5c) (#56016)
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.
2026-07-01 12:30:59 +10:00
syahidfrd
0198713c33 fix(security): reuse auth chain when tagging unverified senders in Slack threads
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.
2026-06-30 18:05:43 -07:00
CRWuTJ
8ad15ff7dd fix(telegram): cancel delayed deliveries on disconnect
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.
2026-06-30 17:39:30 -07:00
teknium1
7de485703b fix(gateway): preserve media + reply payload when /queue defers a turn
/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>
2026-06-30 17:32:35 -07:00
teknium1
36bfe3a449 fix(anthropic+feishu): model-gate max_tokens fallback; wire Feishu channel_prompt
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.
2026-06-30 17:20:41 -07:00
codexGW
608e8a6062 fix(discord): accept raw direct bot mentions and ignore bare mention-only pings
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>
2026-06-30 16:38:31 -07:00
PRATHAMESH75
e55e9fad2c fix(telegram): recover when polling updater stops while process stays alive
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.
2026-06-30 15:36:58 -07:00
Erosika
1f1d346ced fix(profile): resolve WhatsApp media-path cache roots per-call
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.
2026-06-30 15:30:06 -07:00
Erosika
96aafecadd test(profile): prove isolation fix under the multiplexed gateway, not just desktop
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.)
2026-06-30 15:30:06 -07:00
kshitijk4poor
7b12753948 feat(gateway): expose platform_connect_timeout in config.yaml
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
2026-06-30 15:03:25 -07:00
konsisumer
46ab06c238 fix(gateway): honor Discord connect timeout for ready wait 2026-06-30 15:03:25 -07:00
teknium1
fd2d054d8b fix(gateway): strip [[as_document]] even without a MEDIA: tag
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.
2026-06-30 14:29:56 -07:00
HexLab98
6b89439ef1 test(gateway): cover extension-less MEDIA delivery
Add regression tests for Caddyfile-style paths in MEDIA: tags and for
strip_media_directives_for_display on the streaming path.
2026-06-30 14:29:56 -07:00
teknium1
dc3d435f9d fix(gateway): deliver confirmation + reuse handlers for plain-text approvals
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.
2026-06-30 14:29:51 -07:00
nnnet
5582b51a68 fix(gateway): stop poisoning the LLM prompt with STT-mode chatter
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>
2026-06-30 04:23:03 -07:00
fayenix
d6c53dcdcb fix(gateway): stop per-turn agent-cache eviction from model + message_id signature churn
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>
2026-06-30 04:22:41 -07:00
teknium1
af5cea04ab fix(discord): split oversized final edits, truncate mid-stream previews (#27881)
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>
2026-06-30 03:49:52 -07:00
jasonQin6
6dd188d786 fix(gateway): add session staleness guard to stream consumer
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>
2026-06-30 03:42:25 -07:00
Kong
24aa02179b test(whatsapp): repoint owner test import after adapter relocation
WhatsAppAdapter lives under plugins/platforms/whatsapp/adapter.py on
current upstream; the owner-forward test still imported the removed
gateway.platforms.whatsapp module.
2026-06-30 03:41:43 -07:00
Keira Voss
a61cf774ce feat(whatsapp): tag owner-typed inbound text with [owner reply] prefix
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.
2026-06-30 03:41:43 -07:00
keiravoss94
84f350efe0 feat(whatsapp): opt-in forwarding of owner-typed messages in bot mode
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.
2026-06-30 03:41:43 -07:00
UgwujaGeorge
cb9d18c759 fix(gateway): stop media-send fallbacks from leaking host paths into chat
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.
2026-06-30 03:24:36 -07:00
teknium1
fee3d4ed04 test(gateway): update startup-restart-race fixtures for current main
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.
2026-06-30 03:22:18 -07:00
Disaster-Terminator
f4a54b6292 fix(gateway): abort startup during restart 2026-06-30 03:22:18 -07:00
teknium1
b6045170bb fix(discord): extend channel-name matching to slash-command auth; clamp flush deadline to disconnect budget
Follow-up to the salvaged #8008 fix:
- Sibling-site fix: _evaluate_slash_authorization gated DISCORD_ALLOWED_CHANNELS /
  DISCORD_IGNORED_CHANNELS on numeric IDs only, so name/#name config that now works
  for on_message still silently failed for slash-command interactions. Refactor the
  channel-key helper to _discord_channel_keys_from_channel(channel, parent) and reuse
  it at the interaction gate. Fail-closed on missing channel id is preserved.
- The contributor's hardcoded 8s flush deadline could be hard-cancelled mid-flush:
  _teardown_adapter already wraps cancel_background_tasks() in the per-adapter
  disconnect budget (HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT, default 5s). The flush
  deadline now derives from that budget with headroom so it always completes inside it.
- AUTHOR_MAP: map cypher@augmentl.com -> Nickperillo for CI.
- Tests: slash-auth name/#name allow + name ignore matching.
2026-06-30 02:48:42 -07:00
Cypher
cb9308f0a6 fix(discord): channel name matching and flush pending sends on shutdown
Two related fixes to the Discord gateway adapter:

1. Channel name matching (free-response, allowed, ignored, no-thread channels)
   Previously these config values only matched against numeric channel IDs.
   If a user configured free_response_channels: cypher (by name), the adapter
   would silently ignore it because it only intersected against channel_ids.
   Now the adapter builds a channel_keys set that includes the channel ID,
   channel name, and #channel-name form, and checks all three for each gate.

2. Flush pending text-batch tasks before shutdown
   The Discord adapter uses _pending_text_batch_tasks (its own dict) for
   merging rapid successive message chunks. These tasks were NOT added to
   self._background_tasks (the base class list), so the base
   cancel_background_tasks() never awaited them on restart/shutdown.
   This caused a race: in-flight response deliveries were cancelled before
   Discord had a chance to send them, resulting in silent dropped messages
   visible to users as tool-log-only replies with no text body.

   Fix: override cancel_background_tasks() in DiscordAdapter to await all
   pending text-batch tasks (8s deadline) before delegating to the base class.
2026-06-30 02:48:42 -07:00
David Gutowsky
3a83b6bc5d fix(gateway): self-heal stale sessions.json routing at message time
Detect a routing key whose session is already ended in state.db
(end_reason set) inside get_or_create_session and drop the stale entry
instead of silently routing the message into a closed session.

Previously the only runtime cleanup of sessions.json was the startup
_prune_stale_sessions_locked (#52808/#54138), which requires a restart.
A session ended while the gateway stays alive — any path that finalizes
the DB row without clearing sessions.json — left a live routing key
pointing at a closed session. get_or_create_session never consulted
end_reason, so it returned that stale entry and every subsequent message
was silently dropped (no log, no error, no response) until the next
restart. This is the live-gateway variant of #52804/FM9, which needed an
actual gateway crash.

The guard drops the stale entry and falls through to
_recover_session_from_db, which reopens agent_close-ended rows and
resumes the SAME session_id (transcript preserved); if the row ended for
a non-recoverable reason (e.g. /new) it correctly starts a fresh
session. A warning is logged so the event is visible (the field
incident reported zero log output).

Adds tests/gateway/test_session_store_runtime_stale_guard.py covering
the _is_session_ended_in_db helper and the end-to-end routing self-heal
(recover-vs-fresh, live-entry untouched, stale-wins-over-suspended,
force_new short-circuit).

Closes #54878.

Co-authored-by: David Gutowsky <david.gutowsky@gmail.com>
2026-06-30 13:17:51 +05:30
Ben Barclay
05ac16778b feat(gateway): per-platform typing_indicator toggle
Add a generic per-platform PlatformConfig.typing_indicator flag (default
True) that gates the _keep_typing refresh loop in
_process_message_background. When false, the loop is never spawned, so no
typing/"is thinking…" status is shown on that platform — message delivery
is otherwise unchanged.

Mirrors the gateway_restart_notification contract exactly: dataclass field
+ to_dict/from_dict (with extra-fallback resolution) + shared-key bridge in
load_gateway_config, so 'slack: typing_indicator: false' under platforms
works without a separate block. Generic by design — the same key works for
every platform (Slack 'is thinking…', Telegram/Discord/Signal typing).

Motivated by users who find Slack's assistant 'is thinking…' status noisy
(it also briefly disables the compose box, via the Assistant API).
2026-06-29 21:12:57 -07:00
Ben
184c10cf97 fix(slack): warn when configured token is a user token, not a bot token
A Slack user/legacy token (xoxp-...) makes auth.test resolve to the
installing human's member ID with no bot_id, so the adapter binds its
identity (_bot_user_id / _team_bot_user_ids) to that human. Every
"is this the bot?" check then misfires: that person's <@...> mentions
wake the bot and are stripped as the bot's own mention, so the agent is
genuinely told it was @mentioned and replies to messages merely
addressed to that human (symptom: bot responds to "@trevor ..." and
insists it was explicitly mentioned).

There is no runtime API error to catch — a user token still
sends/receives — so the only detectable moment is connect time. Add a
warning-only nudge (_warn_if_not_bot_token) alongside the existing
group-DM scope nudge: when auth.test resolves a user_id but no bot_id,
log that the token is a user token and to use the xoxb-... Bot User
OAuth Token. Warning-only: does not block a working-but-misconfigured
install. Fires once per workspace per process.
2026-06-29 20:57:43 -07:00
Teknium
6aefc9d925
feat(gateway): show per-category context breakdown in /usage (#55204)
Channel users get the same context split the desktop popover shows
(PR #54907) — system prompt, tools, rules, skills, MCP, subagents,
memory, conversation — under the existing Context line in /usage.

Reuses agent.context_breakdown.compute_session_context_breakdown, so
there is no new tool and no new engine. The slices are estimates
(chars/4) and the block is labelled _(estimated)_; the headline
Context line keeps using the provider-measured last_prompt_tokens.
Rendering is fail-open: any engine error returns no breakdown and the
rest of /usage is unaffected.

- gateway/slash_commands.py: _context_breakdown_lines() helper + wire
  into _handle_usage_command
- locales/*.yaml: breakdown_header, breakdown_line, and 8 category
  labels across all 16 locales (parity gate)
- tests/gateway/test_usage_command.py: render + fail-open coverage
2026-06-29 20:42:19 -07:00
Teknium
481caa66f2
feat(display): friendly human-phrased tool labels for built-in tools (#55166)
* feat(display): friendly human-phrased tool labels for built-in tools

Built-in tools now render ChatGPT-style status verbs ('Searching the web
for ...', 'Reading <file>', 'Browsing <url>') on the CLI spinner and
gateway/desktop tool-progress instead of the raw tool name.

- agent/display.py: _TOOL_VERBS map + build_tool_label() + set/get
  friendly-labels flag (default on). Custom/plugin/MCP tools fall back to
  the raw preview; verbose gateway mode left untouched (debug surface).
- tool_executor.py / tui_gateway / gateway: route the three spinner sites,
  the TUI _tool_ctx, and the gateway all/new progress line through the label.
- config: display.friendly_tool_labels (default True, per-platform aware).

Zero new core tool / schema footprint — pure display layer.

* docs: add PR infographic for friendly tool labels

* fix(display): preserve arg preview in gateway friendly labels + update tests

The first gateway pass re-derived the label from the callback's `args`, which
is empty ({}) at the gateway tool.started callsite — the command/query lives in
the `preview` string, so terminal rendered as a bare '💻 Running' and dedup
collapsed consecutive commands. Now the gateway prefixes the verb onto the
already-computed preview via get_tool_verb/tool_verb_connector/verb_drops_preview,
preserving the command/url/query. CLI spinner path (real args) keeps build_tool_label.

Tests: update test_run_progress_topics exact-format assertions to the friendly
form ('💻 Running pwd'), add a format-agnostic preview extractor for the
truncation tests (works for both quoted-legacy and verb-prefixed output).

* test(tui): update resume-display context to friendly tool label

_tool_ctx now uses build_tool_label, so the desktop resume-view context for a
search_files turn reads 'Searching files for resume' instead of the bare
'resume' preview — consistent with live tool-progress. Update the assertion.

* test(tui): harden no-race worker test against sibling shard leakage

test_session_create_no_race_keeps_worker_alive flaked under -j 8: a daemon
build thread leaked from a prior session.create test in the same shard process
fires close/unregister against its own (foreign) session_key after this test
patches the global approval hooks, polluting the captured lists. Scope the
assertions to this session's own session_key so the regression intent
(this session's worker/notify must survive) is preserved while the test
becomes immune to shard composition. Not related to friendly-tool-labels.
2026-06-29 20:31:17 -07:00
yoniebans
d2ce2c852d test(gateway): assert interleaving safety of concurrent offloaded DB calls 2026-06-29 15:51:57 -07:00
yoniebans
6735162531 fix(gateway): offload the Telegram topic-recovery helper tree off the loop
The topic-mode helpers (_telegram_topic_mode_enabled,
_recover_telegram_topic_thread_id, _record/_sync_telegram_topic_binding,
_is_telegram_topic_lane/_root_lobby, _normalize_source_for_session_key,
_telegram_topic_new_header, _schedule_telegram_topic_title_rename, and the
base.py _apply_topic_recovery hook) each run a synchronous SessionDB read or
write. They reach the event loop through async handlers, so a contended
state.db froze the loop the same way the handoff watcher did.

These helpers already run off-loop in the run_sync thread-pool closure, so
they are proven thread-safe there. Rather than colour them async, loop-side
callers now invoke them via asyncio.to_thread(...); the executor callers are
unchanged. Inside the helpers the SessionDB handle is unwrapped to the sync
door (getattr(db, '_db', db)) since they always run on a worker thread, and
AIAgent construction + query_session_listing are handed the sync SessionDB
directly. base.py wraps its single _apply_topic_recovery call in to_thread.

The guard is now alias-aware (catches db = getattr(self, '_session_db', None);
db.method(...)) and enforces the offload contract: the offloaded sync helpers
may never be called bare on the loop. Sibling test fixtures wrap their injected
SessionDB in AsyncSessionDB to match how the gateway holds it.
2026-06-29 15:51:57 -07:00
yoniebans
0896facce8 fix(gateway): route SessionDB calls through AsyncSessionDB 2026-06-29 15:51:57 -07:00
yoniebans
89daacb454 test(gateway): cover AsyncSessionDB offload + raw-call guard (failing) 2026-06-29 15:51:57 -07:00
Teknium
290fa7fd2b
fix(gateway): skip confirmed-dead delivery targets (deleted groups, blocked bots) (#55115)
* fix(gateway): skip confirmed-dead delivery targets (deleted groups, blocked bots)

A deleted Telegram group, kicked/blocked bot, or deactivated user keeps
throwing Forbidden/not_found on every cron tick and fan-out delivery. Each
retry burns a send against the platform's flood-control envelope and spams
the logs, making the whole session feel broken even when the model call
completed.

Add a small persistent DeadTargetRegistry (per-profile JSON under
HERMES_HOME) that records a target the moment a send reports a whole-chat
death (forbidden / chat-level not_found), and have DeliveryRouter.deliver()
short-circuit it on subsequent attempts. Self-healing: any successful send
clears the flag, so a user re-adding the bot recovers with no manual cleanup.
Thread/topic-level not_found is NOT recorded (adapters already self-heal that
by retrying without reply_to). Transient/timeout errors are never marked dead.

* infographic: dead delivery target skipping
2026-06-29 13:23:29 -07:00
Ben Barclay
b963d3238b
feat(gateway): suppress home-channel shutdown broadcast on flagged drains (#54824)
Add a generic suppress_notification flag to the drain-request marker. When a
drain that ends in process exit (e.g. a NAS auto-update image migration on the
always-on Hermes Cloud fleet) is flagged, the gateway skips ONLY the
home-channel 'gateway shutting down' broadcast — the operator-flavoured ping
that would otherwise fire on every routine auto-update, dozens of times a day.

The per-active-session interrupt ping is ALWAYS kept: on a drained shutdown
it's empty by construction, and in the force-interrupt (deadline-exceeded) case
it carries the user-valuable 'your task was cut off, message me to resume' hint.

The gateway stays agnostic about WHY a drain is quiet (generic boolean, not a
kind enum); the policy of which drain causes set the flag lives in the caller
(NAS). Default-false so legacy/operator drains behave exactly as before. The
reader reuses the NS-570 epoch-staleness check so an orphaned marker on the
durable volume can never silence a fresh gateway's legitimate broadcast.

- drain_control.py: write_drain_request gains suppress_notification; new
  drain_notification_suppressed() reader (current-epoch + truthy flag).
- web_server.py: /api/gateway/drain reads + echoes the flag.
- run.py: _notify_active_sessions_of_shutdown skips the home-channel loop only.

Tests prove: flag round-trips; home-channel suppressed when set, kept when
unset; active-session ping always fires; stale/legacy/corrupt markers never
suppress.
2026-06-29 12:18:11 -07:00
Teknium
dbad6d47d3 fix(gateway): also neutralize untrusted Matrix room name in prompt
Widen #5961's _format_untrusted_prompt_value coverage to the Matrix
room display name (**Matrix Room:**), a sibling attacker-controllable
field the original fix missed. chat_name is user-settable, so an
injected room name could render as literal markdown in the system
prompt. Adds a regression test.
2026-06-29 04:25:51 -07:00
Xowiek
09666ceb76 fix(gateway): neutralize untrusted session metadata in prompts 2026-06-29 04:25:51 -07:00
teknium1
ea1372d2af fix(security): wire session-id sanitizer into artifact paths + API boundary
Defense-in-depth on top of _safe_session_filename_component (#5958):

Sink (makes the bad write impossible regardless of entry point):
- run_agent._save_session_log: sanitize session_id before building the
  session_{sid}.json snapshot path.
- agent_runtime_helpers.dump_api_request_debug: sanitize before building
  the request_dump_{sid}_{ts}.json path.

Boundary (clean 400 instead of a silently-hashed filename):
- api_server rejects path-traversal-shaped X-Hermes-Session-Id on the
  session-continuation path and the explicit /api/sessions create path,
  reusing gateway.session._is_path_unsafe (mirrors the native gateway's
  entry-boundary guard). Also enforces the session-header length cap on
  the continuation path.

Tests: traversal session_id stays contained at the write site; sanitizer
always yields a traversal-free segment; the API header rejects
../, absolute, and Windows-traversal IDs with 400.
2026-06-29 04:25:45 -07:00
teknium1
cdd8e0a271 test(gateway): exercise last_prompt_tokens in reset-activity tests
The reset-had-activity tests set total_tokens (dead state) to simulate
activity; production records activity via last_prompt_tokens. Update
the fixtures to match the field the fix and runtime actually use.
2026-06-29 04:25:37 -07:00