cf60ebbdfd264c3fc067b9e3230df8752c50d2ec
345 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d3db321828 | refactor(adapters/base): base.py 7719->5348, helpers.py 942->640; extract _process_message_background helpers, unify media send/fallback/cache/notice paths, drop dead _resolve_extensionless_candidate + TextBatchAggregator, compact docs | ||
|
|
74775df53f |
fix(gateway): route-stamp primary callback auth and carry is_bot through the adapter auth check
Under `multiplex_profiles` the primary adapter's message handler is a
profile closure, so the Telegram inline-button gate (and the early
message prefilter) cannot recover the runner via `_message_handler.__self__`
and fell to env-only auth. #65589 made the gate prefer the injected
`_authorization_check`, but `_make_adapter_auth_check` built a bare
`(user_id, chat_type, chat_id)` source: never route-stamped, never
`is_bot`.
- `_make_adapter_auth_check`: for the shared primary adapter under
multiplex, mirror the inbound message path exactly — stamp the
`profile_routes` match so the routed profile's pairing store is
consulted, and authorize under the TRANSPORT home via
`_is_user_authorized_for_source` (same split as
`_make_default_profile_message_handler`,
|
||
|
|
d55d9d128a |
fix(gateway): route profile into topic prune, cooldowns, and docs
Address hermes-sweeper review on #76487: - Prefer hermes_profile from send metadata when pruning stale topic bindings so profile_routes cannot delete the transport adapter's namespace instead of the routed runtime's - Namespace lobby/capability cooldowns and /topic off cleanup by (profile, chat_id) - Document profile_name PKs and scoped cleanup SQL in telegram.md - Regression: primary-adapter stamp + routed metadata prune isolation |
||
|
|
1cd736ff63 |
fix(terminal): scope terminal config per turn under profile multiplexing
A multiplexed Hermes process (gateway.multiplex_profiles, unified dashboard/TUI, or cron) serves several profiles at once, but terminal.* resolved through process-global TERMINAL_* env vars bridged ONCE at startup from the launch profile (gateway/run.py ~2700-2760) plus the one-shot _ensure_terminal_env_bridged() guard. Every routed profile therefore inherited the launch profile's backend, cwd, docker volumes, SSH target and shared-container key: a local profile ran inside another profile's docker sandbox (or a docker profile escaped to the host), and a container labeled profile A carried profile B's RW bind mounts. Fix: an authoritative per-profile terminal policy seam, mirroring agent/secret_scope.py: - tools/terminal_scope.py: ContextVar holding the routed profile's COMPLETE effective TERMINAL_* policy (defined defaults <- profile .env TERMINAL_* <- config.yaml terminal:). While bound, terminal_env() resolves ONLY from it - an omitted key yields the defined default, never os.environ. Unreadable/malformed policy installs a refusal scope; terminal_tool / execute_code refuse instead of running under ambient launch-process policy (fail closed). - Installed at every in-process profile boundary: gateway _profile_runtime_scope, tui_gateway session/build/turn scopes, cron per-job fire. The unscoped single-process path is byte-identical. - Every terminal.* consumer reads through the scope: terminal_tool (_get_env_config, _resolve_container_task_id shared key, orphan reaper lifetime, degraded mode), gateway/platforms/base.py docker media translation (volumes, shared key, persistence), runtime_cwd / agent_init / skill_utils / code_execution_tool / file_tools cwd anchors, prompt_builder / browser_tool / env_probe backend checks, gateway footer, @-refs and slash-command cwd. env_probe resolves the backend in the caller's context, since the probe worker thread does not inherit the ContextVar. Salvage of #99225 onto current main: adds the three ambient reads the PR missed (tools/file_tools.py TERMINAL_CWD, tools/browser_tool.py and tools/env_probe.py TERMINAL_ENV; shape from #79117) and trims the test module to the leak matrix driven through the real gateway boundary, omitted-key defaults, refusal, and boundary reset. Fixes #68559 Fixes #94200 Fixes #101132 Fixes #95470 Co-authored-by: x7peeps <9640837+x7peeps@users.noreply.github.com> Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: ExitMaster <292490062+ExitMaster@users.noreply.github.com> |
||
|
|
45b0d8cab5 |
feat(gateway): one gateway.trust_env key controls aiohttp proxy-env honoring at every adapter site (#48820 bug 3)
Every gateway/plugin platform adapter hard-coded aiohttp.ClientSession(trust_env=True) (~20 sites), so a gateway launched by a Windows Scheduled Task that inherits a stale HTTP_PROXY (Clash/V2Ray on 127.0.0.1:7890) looped on 'Cannot connect to host' with no way to opt out short of NO_PROXY hacks per vendor host. - gateway/platforms/base.py: gateway_trust_env() reads gateway.trust_env (default true); resolve_proxy_url() skips generic HTTP(S)_PROXY/ALL_PROXY + macOS system-proxy auto-detect when false (explicit per-platform vars still win). - All aiohttp ClientSession sites in weixin, qqbot, matrix, line, wecom, slack, sms, teams, google_chat now pass trust_env=gateway_trust_env(); mattermost + homeassistant bare sessions gain the same kwarg (intent of #70119 / #56229). - DEFAULT_CONFIG + cli-config.yaml.example + messaging docs. - tests/gateway/test_gateway_trust_env.py: config flip + no-bare-literal sweep. Reported-by: @ranlingfeng (#48820), @frontnopipe-cloud (#76309) Co-authored-by: rcarrata <rcarratalasanchez@gmail.com> Co-authored-by: Backroads4Me <TEDLANHAM@GMAIL.COM> |
||
|
|
00394acfae | fix(buzz): ingest verified native attachments | ||
|
|
aaad054330 |
fix(buzz): gate authenticated inbound media on explicit authorization
Localizing inbound relay media spends the agent's own Buzz credentials on a URL chosen by the sender, so it must not run on the strength of the adapter's local allow-list alone. Require the gateway's authorization callback to return an explicit True before any `buzz media get` runs; a denial, a missing callback, or a raising callback fails closed and leaves the message text exactly as it arrived. `_is_sender_authorized` previously wrapped the callback result in `bool()`, so a truthy non-boolean (a status string, a sentinel) would satisfy an `is True` gate's intent while bypassing its guarantee. Only the literal booleans now propagate; anything else is "unknown", which the existing Slack and Discord callers already treat as trust-unknown. Reviewers asked for this boundary on the sibling inbound-media PRs (#77734, #78051); it applies equally to the retrieval path in #75614, which this change builds on. |
||
|
|
fd1d8271db | fix(cron): isolate lazy imports from stale modules | ||
|
|
74a95a3ddf |
feat: /btw now answers side questions with conversation context; /background renamed to /bg
/bg (formerly /background, which is retired) keeps the existing semantics: spawn a fresh, independent agent session in the background. /btw is now its own command matching the convention other harnesses use: ask a quick side question ABOUT the current conversation without interrupting it. A one-shot auxiliary LLM call (main model by default, overridable via auxiliary.side_question.* in config.yaml) answers from a read-only transcript snapshot — the live session's history, role alternation, and prompt cache are untouched, and the current turn keeps running. Surfaces wired: CLI (inline mid-run dispatch), gateway (all messengers, busy-dispatch table + idle dispatch, i18n across all 17 locales), TUI (prompt.btw RPC + btw.complete event), Discord native slash, relay command manifest, desktop exec routing, docs (EN + zh-Hans). |
||
|
|
ccc367dce0 |
fix(prompt)+feat(gateway): platform-hint truth pass + universal voice-bubble transcode (all 22 hints source-verified) (#97873)
* fix(prompt): platform-hint truth pass — CLI/TUI file-delivery reality (paths/URLs only, MEDIA: prints literally), CLI no-markdown verified live, Slack/Discord markdown+tables truth, shared local-cron constant * feat(gateway): universal voice-bubble delivery — shared transcode_to_ogg_opus; telegram [[audio_as_voice]] any-format; feishu native voice; hints to new truth * chore: delete the webui ghost hint (tombstone comment, audit-verified); sync send_voice signature pin in tts routing test |
||
|
|
272f4e4abe |
feat(plugins): generalize native platform handler registration to every gateway platform
ctx.register_platform_handler(platform, factory) — the generic surface for plugins to wire native handlers into any platform adapter at connect() time. Factories receive (native, adapter): the platform's client/app object (PTB Application, discord.py Bot, slack_bolt AsyncApp, Teams App, DingTalkStreamClient, aiohttp web.Application) or None for adapters with no separate native object. - BasePlatformAdapter._wire_plugin_handlers(native): shared, isolated invocation helper — a raising plugin cannot block a platform connect. - All 27 connectable adapters call it: telegram/slack/teams/line/ api_server/msgraph_webhook wire before their dispatch tables freeze; the rest hook at connect success. - register_telegram_handler and get_telegram_handler_factories retained as thin back-compat aliases over the telegram bucket. - Source-invariant test guarantees every adapter with connect() keeps calling the hook. |
||
|
|
8e1db41041 | fix(gateway): redeliver transient failures after reconnect | ||
|
|
82b32f32ef |
feat(terminal): wire shared-container key into profile-scoped resolver and MEDIA delivery
Follow-up on @fangliquanflq's opt-in (#84775): after the profile-scoping fix (#94560) the container cache key is resolved in _resolve_container_task_id, so the shared key must unify profiles there too — 'shared:<key>' for every session of every opted-in profile AND for CLI/no-session runs. Delivery adds the shared sandbox layout as the first translation candidate. Empty key keeps strict per-profile isolation; SSH ignores the key entirely. |
||
|
|
15f7b7293c |
fix(terminal): persistent Docker containers are profile-scoped, not per-session
Commit a270c4ade's session-key fallback in _resolve_container_task_id was added to stop cross-profile SSH environment reuse, but it wasn't backend- gated: persistent Docker silently fragmented into one container per gateway session, breaking the product contract (one long-lived container per profile, shared by CLI and every session of that profile). #93950's vanishing MEDIA attachments were downstream damage. - persistent Docker (container_persistent: true) now keys to the profile: literal 'default' for the default profile (same container as CLI), 'profile:<name>' for named profiles - SSH and non-persistent Docker keep session scoping (the original leak fix and the #82731 isolation contract are untouched) - gateway MEDIA translation follows the profile layout and keeps the legacy bug-window per-session sandboxes as fallback candidates, trying each until the file resolves — old sessions self-heal, no migration - /root/.hermes credential-surface refusal preserved across all layouts |
||
|
|
a15533b646 |
feat(gateway): warn when a Docker sandbox MEDIA path fails translation
De-silence the #93950 failure mode: when a container-absolute MEDIA path under /workspace or /root cannot be resolved to a host sandbox file while TERMINAL_ENV=docker, log the reason (no mounts / no prefix match / host file missing) plus the delivering session key instead of only the generic 'Skipping unsafe MEDIA directive path' line upstream. |
||
|
|
d4f31a8f36 |
fix(gateway): resolve session-scoped Docker sandboxes for MEDIA delivery (#93950)
Persistent Docker containers bind <sandboxes>/docker/<task>/{workspace,home}
where <task> is sanitize_task_id_for_path("session:<session_key>") — but the
gateway's synthetic mounts hardcoded the literal "default" sandbox
(_default_docker_workspace_host_root / _docker_persistent_home_host_root).
For any session-scoped deployment the longest-prefix match missed, the
container path fell through to a host-filesystem resolve that could not
exist, and every MEDIA attachment was silently dropped.
The post-handler delivery pipeline also runs after
_handle_message_with_agent cleared the turn's session contextvars, so even
a correct sandbox derivation consulting ambient state would collapse onto
"default". Thread the delivering session's key explicitly through
validate_media_delivery_path -> _translate_docker_container_media_path ->
the two host-root helpers (same pattern as the TTS fix for #57049/#36685).
Default-sandbox resolution and the /root/.hermes credential exclusion are
preserved; contexts without a key keep the historical behavior.
|
||
|
|
3683e70043 |
feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half) (#85796)
* feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half)
NS-658. Three additive ops within contract v1, emitted only when the
connector's negotiated descriptor advertises them:
{op: draft, chat_id, draft_id, content, final, metadata}
{op: task_card, chat_id, card_id, chunks, metadata}
{op: task_card_stop, chat_id, card_id, metadata}
The gateway side is deliberately dumb: no platform API knowledge, no new
config keys. Slack mechanics (chat.startStream/appendStream/stopStream,
per-workspace feature-gate cache, send+edit fallback) live connector-side
where the platform adapter lives in the relay model.
Semantic bridge: base send_draft is Telegram-shaped (draft clears; final
is a separate send). Slack native streaming makes the stream THE message.
The adapter tracks the open draft per chat and converts the turn-final
send() into draft(final=true) so the connector seals the stream instead
of posting a duplicate; the stream ts returns as the message identity.
A failed frame disarms interception so the edit-based fallback's real
send goes through untouched.
BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now
requires the descriptor flag AND the draft op. Flag-only was a latent
lie — send_draft inherited NotImplementedError, so a connector setting
the flag without the op would have crashed the stream consumer's draft
path. supported_ops stays fail-open for legacy (pre-contract) ops;
draft/task_card did not exist pre-contract and must not fail open.
Task cards ride #85476's adapter-agnostic TurnRunner seam (hasattr on
send_native_task_card_progress); supports_native_task_cards() is the
descriptor probe. Connector half + E2E harness pair follow in the gg
repo.
* fix(relay): expose native_task_cards_enabled() on the relay adapter
Live-canary finding (Alice, staging): the TurnRunner's task-card lane
probes adapter.native_task_cards_enabled() (the native Slack adapter's
opt-in contract). The relay adapter only offered
supports_native_task_cards(), so the hasattr gate failed silently and
tool progress stayed on the text path — draft streaming worked, cards
never rendered. Alias it to the descriptor probe.
* fix(relay): match task-card methods to the TurnRunner's native keyword contract
Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls
send/stop_native_task_card_progress with the NATIVE Slack adapter's
signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) —
PR 85796's relay methods took a positional card_id, so every call raised
TypeError('unexpected keyword argument reply_to') in the progress task,
repeatedly killing the card publisher (and the retry loop resent the
final delivery 4-5x). Card id now derives per turn thread
(turn:<reply_to>), thread_ts anchored like draft; title/fallback_text
accepted for parity, not forwarded (plan-mode stream renders chunks).
* fix(relay): one draft stream per turn for stream-is-the-message adapters
Live-canary finding #4 (Alice, staging): the stream consumer bumps
draft_id at every tool boundary so Telegram-shaped drafts animate each
text segment as a fresh preview. On relay Slack NATIVE streaming a new
draft_id opens a brand-new chat.startStream — the user saw one frozen
message per segment (stuck streaming cursor ▉, never sealed: only the
LAST stream gets the final=true seal) plus the real final; 5-6 cumulative
snapshots per turn. Adapters that mark draft_stream_is_message keep ONE
stream per turn: tool progress lives in the native task card, and the
connector's suffix-delta falls back to whole-text append on prefix
mismatch, so segments append cleanly. Telegram-shaped drafts keep the
per-segment bump.
* fix(relay): don't seal the native stream at tool boundaries — only the turn-final does
Live-canary finding #5 (Alice; supersedes the incomplete #4 which was
necessary but not sufficient). Root cause CONFIRMED by integration trace
(test_live_cards_flow_trace.py, real consumer semantics + real adapter +
stub transport): at every tool boundary the consumer calls
_send_or_edit(finalize=True), which skips the draft path and issues a
real send(); the relay adapter's seal-interception converts THAT into
draft(final=true) — sealing the stream once per segment. Timeline showed
3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots
seen live (the replaced stream never gets stopStream, keeping its cursor).
Fix: for draft_stream_is_message adapters, a segment-break finalize
(finalize=True, is_turn_final=False) stays ON the draft path as another
cumulative frame; only got_done (is_turn_final=True) falls through to
send() and seals. Telegram-shaped platforms unchanged. Trace test now
pins the invariant: ONE user-visible message per turn.
* fix(relay): strip the text cursor from native draft frames
Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism,
confirmed by full-flow scan of both sides' code + logs. The consumer
appends its text cursor (▉) to every non-final display_text tick. The
connector's stream sender diffs CUMULATIVE frames via prefix check:
'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits
mid-string), so deltaFor falls back to whole-text append on EVERY tick —
chat.appendStream stacks each full cumulative snapshot (cursor included)
into the ONE stream message. Exactly the observed thread: repeated
blocks, each ending in a frozen ▉, growing per tick.
Fixes #4/#5 were real (one stream per turn now) but this was the last
mechanism standing. Native streams render their own typing indicator, so
the text cursor is pure noise on this path: strip it from draft frames.
Prefix check now holds; every tick appends only its true suffix delta.
* fix(relay): seal-interception covers EVERY egress door, not just send()
Live-canary finding #7 (Alice): one duplication remained after #6 — the
stream froze mid-word with the live indicator (never sealed) and the
final posted as a separate message. Log receipt: 'Queued follow-up:
final text delivery confirmed; delivering explicit media before
continuing' — the turn's final went out via the DELIVERY RESOLVER lane
(gateway/delivery.py), which calls send_for_platform() DIRECTLY,
bypassing send() and its seal-interception. The open stream never
absorbed the final; it arrived as a plain 'send' op → chat.postMessage.
Fix: hoist the open-draft check to the top of send() (ahead of the
explicit-platform branch) AND add it to send_for_platform() — an open
native stream absorbs the turn-final regardless of which egress door it
arrives through. The stream IS the message.
* fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1)
A turn-final seal that fails at the transport must never swallow the
final answer: the stream consumer has already disabled the draft
transport for the run, so a failed _seal_open_draft returning
success=False meant the user got NOTHING. Both seal-interception sites
(send + send_for_platform) now fall through to the regular plain-send
path on seal failure, with a warning receipt. Also mitigates AI-review
point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale
entry's failed seal no longer blocks the next turn's delivery.
* fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1)
Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the
wire but its ack channel is lossy — send_outbound timeout (30s) and
WS-drop 'failures' frequently mean the frame WAS delivered and the
connector stream is open. send_draft popped _open_draft_by_chat on any
failure, disarming seal-interception while the connector stream lived:
the turn-final went out as a plain send → orphaned mid-word stream +
complete duplicate final (intermittent; needs a drop/timeout inside the
draft window).
Fix: arm the entry BEFORE the transport call and keep it armed on
failure/exception. Safe in every case: sealing a non-existent stream
opens+seals a single complete message connector-side, and a truly failed
seal already falls back to plain send at both interception sites.
Stale-entry damage is self-healing (one warning + plain send).
* fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams
Regression fix on G-D1 (live: 'worse than before' — escalating frozen
prefixes). Optimistic arming had no seal-awareness: a straggler frame
arriving AFTER the seal re-armed _open_draft_by_chat for the already-
sealed draft_id; the next send was converted to draft(final=true) on the
tombstoned connector key, which CLEARED the connector tombstone (final
frame = new-turn signal), re-opened a stream with cumulative content,
and left it frozen — repeating per straggler: 4-5 escalating frozen
snapshots. Mirror the connector: _sealed_draft_by_chat records the
sealed draft_id per chat (tombstoned BEFORE the seal's transport call);
send_draft for a sealed draft_id is a success no-op (content already in
the sealed message) and never arms. A new turn's fresh draft_id arms
normally.
* fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10)
Live finding #10 (Alice; three concurrent turns in one flat DM): all
coordination state was keyed per CHAT on a one-active-turn assumption.
Three parallel turns produced: turn B's task card merged into turn A's
(both were card 'turn:root' — reply_to is None in flat DMs), B left
cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x
duplicate finals on the last turn). Per-turn machinery was correct;
the keys were not.
Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor
(inbound stamps thread_ts = event.thread_ts or ts on every top-level
message, so each turn has one even in flat DMs). draft arming, seal
tombstones, both interception sites, and the task-card id all derive
from the same anchor. New trace test pins two interleaved turns:
distinct cards, own-stream seals, no leaked plain send, no cross-turn
tombstone drops (289 tests green).
* fix(gateway): preserve cumulative native stream across tools
* fix(gateway): consumer-declared final — the seal carries the true final
Three composed fixes for the Slack live-cards duplicate-final class:
1. finish(final_text): TurnRunner passes the completed final_response
(verifier footer, completion explainer included) as the authoritative
finalize payload. The native-stream seal delivers the TRUE final, so
post-stream mutation no longer forks a corrective plain send (#11).
2. Interim-send contract: commentary and segment-tail sends carry a
gateway-internal _interim_send marker; relay seal-interception skips
them at both egress doors. A mid-turn interim send can no longer seal
the live stream and orphan the real final into a duplicate.
3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the
consumer's delivered message in place (sealed stream = regular
message, chat.update live-verified); plain send only as fallback.
This was the actual duplicate lane in the parallel canaries — every
duplicated turn logged 'final stream delivery not confirmed; sending
first response' (subagent-completion queued inbound), not parallelism.
Also: draft frames stay prefix-stable gateway-side (no fence-closing, no
segment state reset, no commentary reset for stream-is-the-message
adapters; MagicMock-safe 'is True' guards).
* test+docs: streaming-contract coverage completeness + maintenance guidelines
Coverage: two gaps closed on the consumer-declared-final contract —
(1) send_for_platform (the delivery-resolver egress door) honors the
_interim_send contract: no seal, marker stripped before the wire;
(2) finish(final_text) on a turn that never streamed does not adopt the
final (delivery ownership stays with the gateway's normal send path for
non-streaming models / tool-only turns).
Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract —
the four invariants of stream-is-the-message adapters (prefix-stable
frames, consumer-declared final, interim-send marker, reconcile-by-edit),
each traced to its live incident, plus the live-probed Slack streaming
API ground truth and the MagicMock 'is True' guard-style note.
* fix(relay): seal transport failure must never silently lose the final (review B1)
Two halves of one silent-loss path, live-probed on the review branch:
1. adapter: _seal_open_draft did not catch transport exceptions. A socket
drop at seal time raised out of send(), skipping the fail-open plain
send entirely. Now: retry the SAME idempotent final frame once (the
connector's sealed-key tombstone returns the original stream ts for a
repeated final — a retry can never open a second stream or duplicate),
then report failure so the caller's fail-open path runs.
2. consumer: the turn-final retry (elif not _already_sent) called
_send_or_edit with finalize=False, which re-entered the DRAFT-FRAME
branch. Its no-op dedupe compared the adopted final against the last
unsealed frame, matched, and returned True with ZERO transport calls —
final_response_sent went green, delivered_final_matches reconciled,
the gateway suppressed its fallback, and the user never received the
answer. finalize=True keeps this retry out of the draft branch.
Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests).
Mutation evidence in follow-up verification: reverting either half sends
the suite red.
* fix(relay): draft ids unique across gateway incarnations (review B3)
The relay connector tombstones sealed streams by (channel, draft_id) and
keeps up to 512 of them; they outlive the gateway process. Relay gateways
are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted
at zero every incarnation — so the first turns after every scale-from-zero
in a recently-active channel replayed already-sealed wire identities. The
connector answered those frames straight out of the old tombstone: zero
Slack API calls, the OLD message ts returned as the new turn's identity,
the new answer silently dropped while gateway-side flags recorded success.
Seed the counter from wall-clock milliseconds at process start. Ids stay
plain ints within the existing contract op; incarnations cannot overlap
for realistic turn counts and restart gaps.
Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed
test fails on the old code (seed 0 is not epoch-scale).
* fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2)
The thread anchor is the wrong coordination identity — simultaneously:
- too coarse: two parallel turns replying INSIDE ONE Slack thread share
thread_ts. Live-probed on the review branch: turn A's final sealed turn
B's stream with A's content while A's own stream stayed open, and B's
final degraded to a plain send.
- too fragile: a flat DM with no thread metadata degraded to the bare
chat id, re-creating the original finding-#10 collision the anchor was
meant to fix.
_draft_key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction; the gateway's Slack
thread metadata and the consumer's send path both stamp it), falling back
to the thread anchor, then the bare chat. The consumer stamps the same
reply_to_message_id on draft frames so frames and the turn-final resolve
to one key. Task-card ids share the derivation via _card_key (one helper
for send AND stop, so the stop always hits the stream the send opened).
Legacy resolver-lane callers with placement-only metadata still seal via
_match_open_draft's fallback — but ONLY when exactly one stream is open.
With several open, an identity-less send stays a plain send: a duplicate
message is recoverable, sealing someone else's stream is not.
Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests).
* fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4)
draft_stream_is_message was hardcoded True on the relay adapter class,
i.e. for EVERY relay platform. The base send_draft contract is
Telegram-shaped — the draft clears client-side and the final arrives as
a separate real send that becomes the history message. With the flag
forced on, any non-Slack connector advertising the draft op had its
turn-final intercepted into draft(final=true): probed on the review
branch with a telegram descriptor, the op stream was
[draft(final=false), draft(final=true)] and NO send — no history message
would ever be posted.
Gate the flag on the negotiated descriptor platform (slack), and skip
arming seal-interception entirely when it is off. A future platform with
genuine stream-is-the-message native streaming should advertise it via
the descriptor rather than widening the platform check by guesswork.
Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py
(4 tests: gating both ways, telegram final is a real send, slack final
still seals).
* fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5)
Seal-interception treats the first unmarked send to an armed (chat, turn)
key as the turn-final. The consumer's own interim lanes (commentary, tail
flush) carry _interim_send, but four gateway-side lanes that fire DURING
a streaming turn did not:
- long-running heartbeat (default every 180s — probed live: at 3 minutes
it sealed the live stream with '⏳ Working — 3 min', the real final
posted as a duplicate, and later frames were silently swallowed by the
seal tombstone)
- inactivity warning
- plain-text approval fallback (button lane failed)
- background-review notice
Add _interim_metadata() beside _non_conversational_metadata and wrap all
four call sites. The marker is gateway-internal; the relay adapter strips
it before the wire (existing behavior, pinned by test).
Note for follow-up: the opt-out shape remains fragile — any FUTURE
unmarked mid-turn send lane re-creates this bug. Inverting the contract
(explicitly mark the one turn-final send) is the durable fix but touches
every adapter's final-delivery path; deliberately kept out of this
review-fix series.
Regression: tests/gateway/test_interim_send_lanes.py (4 tests).
* fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6)
The finish(final_text) adoption gate checked only 'not failed', but the
interrupt/abort returns in agent/conversation_loop.py are
{completed: False, interrupted: True, final_response: 'Operation
interrupted during …'} with NO failed key. Adopting that diagnostic:
1. sealed the user's streamed partial answer over with the interrupt
text (stream-is-the-message: the seal rewrites the whole message), and
2. recorded the diagnostic as the turn-final payload, so
delivered_final_matches reconciled and the gateway suppressed its own
error-delivery path — the diagnostic became the ONLY thing delivered.
Enumerated all 27 final_response-bearing return shapes in
conversation_loop.py: every non-happy-path shape carries completed:
False (several with a diagnostic final_response and neither failed nor
interrupted — retry exhaustion, truncation, codex-incomplete); the happy
path routes through turn_finalizer.finalize_turn (completed=True). Gate
is therefore: not failed AND not interrupted AND completed is not False.
Results lacking the completed key entirely (older callers/test doubles)
keep the previous behavior.
Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests,
incl. a source-level pin on the run.py call site).
* fix(relay): task-card transport failures degrade to failed SendResults (review B7)
send_native_task_card_progress and stop_native_task_card_progress let
transport exceptions escape. The stop runs inside the progress loop's
finally block on the turn-cleanup path, and the post-cancel awaits in
gateway/run.py caught only CancelledError — a socket drop during a card
publish/stop therefore aborted cleanup BEFORE the final-delivery
bookkeeping ran.
Three layers, outermost defends any adapter:
- both adapter methods catch transport exceptions and return failed
SendResults (progress is advisory; the TurnRunner's text fallback
already handles failure results)
- the progress loop's finally wraps the stop (best-effort; the connector
seals orphaned card streams on its own via recycling/eviction)
- the cleanup awaits log-and-continue on non-cancellation errors so
final-delivery bookkeeping always runs
Regression: tests/gateway/relay/test_relay_task_card_failures.py.
* fix(relay): a dying turn seals its native stream instead of orphaning it (review B8)
Stale-generation exits (/new, /stop mid-stream) and cancellations
returned from the consumer's run() with the native stream still open:
- the Slack message kept its live streaming indicator forever (the
cancellation best-effort edit only runs when _message_id exists, and
the native draft path deliberately keeps it None);
- the adapter's armed interception state survived the turn, so the next
turn on the same key could inherit it and seal a dead draft_id.
New adapter op abandon_open_draft(chat, content): seals in place with
the text already on screen (the consumer passes its last delivered
frame) — the seal adds nothing and claims nothing; delivery flags are
never set, so the gateway's normal paths still own whatever happens
next. Best-effort by contract (failure reported, never raised); the
connector reaps truly orphaned streams via recycling/eviction.
The consumer calls it from both death paths: the stale-generation early
return and the CancelledError handler.
Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests,
incl. the next-turn-inheritance hazard).
* fix(relay): bound the draft/seal coordination dicts (review M1)
_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn wrote a permanent entry — unbounded growth for the life
of a long-running gateway process (the docstring said 'one entry per
chat', which stopped being true when the key gained the turn anchor).
_open_draft_by_chat could grow the same way via abandoned entries.
FIFO-evict both at 512 entries — the same idiom as the sibling bounded
cache (_auto_thread_by_chat, capped at 256) and the same size as the
connector's own tombstone store. The straggler window the tombstone
exists for is seconds long; FIFO is more than enough.
Regression: tests/gateway/relay/test_relay_state_bounds.py.
* fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3)
The G-D1 optimistic-arming change silently dropped disarm-on-failure
entirely: after an EXPLICIT connector rejection (success=False result —
not a transport ambiguity), interception stayed armed even though the
stream consumer disables the draft transport on that failure and falls
back to edit-based streaming. Its turn-final would then be converted
into a seal on a stream the connector just told us is unusable.
test_draft_failure_result_propagates claimed to cover this ('must NOT
leave seal-interception armed') but passed for an unrelated reason: the
stub's canned failure also failed the SEAL, whose fail-open path did the
plain send.
Split the two semantics and pin each honestly:
- explicit rejection (result success=False): disarm — turn-final is a
real send (test_draft_failure_result_propagates, now testing what its
comment says)
- transport exception: ambiguous, stay armed — turn-final still seals
(test_draft_transport_exception_keeps_interception_armed, the G-D1
contract)
Also corrects commit ba3a24a's claim ('a failed frame disarms
interception so the edit-based fallback's real send goes through
untouched') to hold again for the rejection case it described.
* fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1)
The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out"}. The round-1
ambiguity handling keyed entirely on the exception channel, so the shape
production actually produces was misclassified as a definite connector
rejection. Probed on the head:
- lost SEAL ack: skipped the idempotent retry, fell straight to a plain
send — duplicate final whenever the seal had actually applied;
- lost FRAME ack: the round-1 disarm-on-rejection fired — interception
disarmed, frozen native stream beside a plain final. This re-created
the original G-D1 ambiguous-ack defect on the result channel.
Contract now spans both channels:
- transport: the ack-timeout branch tags ambiguous=True. The fail-fast
branches (closing / not connected) never sent anything and stay
unmarked — they are definite non-delivery.
- adapter frame path: ambiguous results keep interception armed (same
as exceptions); only definite rejections disarm.
- adapter seal path: one shared _attempt() classifier — exception and
ambiguous result both mean "unknown"; the SAME idempotent frame is
retried once (connector tombstone returns the original stream ts for
a repeated final). Only after both attempts stay ambiguous does the
caller's fail-open plain send run: a possible duplicate after double
ack loss beats a silent loss, and double ack loss on one socket
almost always means the transport is down for the plain send too.
Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests,
incl. a source-of-truth check that the transport tags the timeout branch
and leaves fail-fast branches unmarked).
* fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2)
One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and egress is tagged per chat — but the
round-1 gate keyed draft_stream_is_message and supports_draft_streaming()
off the PRIMARY scalar descriptor. Probed on the head:
- Slack primary + Telegram chat: the Telegram chat's turn-final was
intercepted into draft(final=true) — no real Telegram history message;
- Telegram primary + Slack chat: the Slack chat was denied native
streaming entirely.
Resolve both through _descriptor_for_chat — the same per-chat machinery
max_message_length already uses (added for the identical class of bug:
the primary's 39000-char cap over-sending into Discord 400s):
- new stream_is_message_for_chat(chat_id) on the adapter; arming and
NotImplementedError gating use it. The class attribute remains as the
single-platform value and legacy-probe fallback.
- supports_draft_streaming() gains an optional chat_id kwarg (base
signature updated; single-platform adapters ignore it). The consumer
passes chat_id with a TypeError fallback for out-of-tree adapters.
- the consumer's four draft_stream_is_message reads collapse into one
_stream_is_message() helper that prefers the per-chat probe
(class-resolved, MagicMock-safe) over the attribute.
Platform-name inference ("slack") stays deliberate: a descriptor-level
semantic field is the right eventual contract but is a cross-repo wire
change — noted for the gg follow-up so future platforms advertise the
semantic explicitly.
Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py
(5 tests: both starvation directions, scalar fallback, per-chat
capability gate).
* fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3)
The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns
— correct (#78541: sealed heads would repeat inside the tail) but it was
absolute: a post-split verifier footer never entered the ledger,
delivered_final_matches() reported a mismatch, and the gateway resent
the ENTIRE body+footer after the split chunks (the #11 duplicate class,
one level up).
When the authoritative final strictly prefix-extends the split ledger,
the missing suffix is the only undelivered content: append it to the
live tail and the ledger, so the finalize carries it and the recorded
payload reconciles. Non-prefix rewrites keep the full-resend fallback —
a rewrite cannot be patched onto sealed heads.
Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests:
suffix rides the tail + reconciles, rewrite still mismatches, unsplit
adoption unchanged).
* fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4)
_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O — correct ordering for the straggler race,
but CancelledError is not an Exception: a cancel during the await
bypassed all failure handling, leaving the remote stream live (visible
streaming indicator until connector eviction) while the local state said
'nothing open'. The consumer's abandon pass — added for exactly this
turn-death case — found nothing to close and no-oped.
On CancelledError: restore the open entry, drop the premature tombstone
(only if it is still ours), re-raise. The abandon path then seals the
stream in place with the on-screen text.
Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2
tests: state restoration, and end-to-end cancel→abandon→remote seal).
* fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5)
_match_open_draft's single-open-stream fallback was dead for its primary
intended callers: metadata carrying thread_ts/thread_id (placement-only
resolver lanes) was classified as having 'turn identity', so those sends
never reached the fallback — probed: a plain final posted beside the
still-open turn-keyed stream.
Only per-turn MESSAGE ids are identity now. Thread-anchored and bare
callers share the fallback: absorb into the chat's open stream when
EXACTLY one is open; stay a plain send when several are (duplicate is
recoverable, wrong-stream seal is not). Callers WITH a message id whose
key misses never fall back — their identity is authoritative and a miss
means the stream belongs to a different turn.
Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored
seal, both ambiguous-stay-plain shapes, id-mismatch never steals).
* fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6)
The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay
class but is not a uniqueness guarantee: two gateways starting in the
same millisecond, a forked process inheriting the class state, or a
clock step backwards can all mint colliding wire identities against the
connector's per-(channel, draft_id) tombstone store.
Seed from secrets.randbits(49) instead: collision probability negligible,
no clock dependence, and ids + realistic per-process turn counts stay
comfortably inside the connector's JS number range (draft_id?: number,
2^53). Regression test now spawns two real interpreters and asserts
their seeds differ — the exact scale-to-zero restart shape, and both
start within the same second so a clock-locked seed would fail it.
* fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5)
The connector (gateway-gateway#210) fills chat.startStream's
recipient_user_id / recipient_team_id — required by Slack when
streaming to a channel — from metadata.user_id / metadata.scope_id.
The gateway stamped only slack_team_id per-turn and left user_id (and
scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed
on chat_id alone and overwritten by every inbound message: with users
U1 and U2 running overlapping turns in one channel, U2's arrival
overwrote the cache before U1's stream opened, and U1's stream carried
U2 as recipient_user_id.
_thread_metadata_for_source now stamps scope_id and user_id from the
turn's OWN source (setdefault — explicit values win), so identity is
turn-scoped data on the wire. _with_scope is unchanged and fill-only:
the caches keep serving restart/synthetic sends that carry no per-turn
identity, which is all they were ever safe for.
Mutation evidence: reverting the run.py hunk sends
test_thread_metadata_stamps_per_turn_user_and_scope and
test_concurrent_turns_carry_their_own_identity red; restore returns
green. The _with_scope fill-only tests pass on both trees (existing
correct behavior, now pinned against regression).
---------
Co-authored-by: Ben Barclay <ben@nousresearch.com>
|
||
|
|
21260c3281 |
fix(gateway): carry the profile in adapter-derived session keys (#88404)
Adapter ingress derives a session key BEFORE the runner stamps source.profile in _make_profile_message_handler, so the namespace fell back to the active profile and every bot in a multiplexed gateway produced agent:main:<platform>:<chat>. A Telegram private chat reports the user's own id as chat.id, identical for every bot, so two profiles sharing one human collapsed onto a single lane: _pending_text_batches, _active_sessions, the busy-session guard and _post_delivery_callbacks are all keyed on that string. A day of production logs across two bots shows 60 flushes, none carrying the secondary profile's namespace. set_owner_profile records credential ownership on the adapter and _session_key_profile resolves the namespace as source.profile -> _owner_profile -> the session store's resolver, so a secondary adapter keys into its own namespace even before the source is stamped. Stamped sources keep priority, so relay/connector ingress, which routes per event rather than per credential, is unchanged. _configure_profile_adapter installs the owner alongside the other handlers, covering startup and reconnect. Every candidate is type-checked as a non-blank str, and every attribute read goes through getattr: adapters are routinely built without BasePlatformAdapter.__init__, and a duck-typed session store returns a truthy non-string that would otherwise be interpolated into the key as agent:<MagicMock ...>:. Also routes the four call sites that passed no profile at all (feishu media batches, raft, slack _session_key_for_source, telegram photo batches) through the same resolver. test_multiplex_busy_input_mode's secondary-adapter busy case seeded _active_sessions with the unstamped agent:main: key, asserting the pre-fix collapse. It now seeds the lane the profile-owned adapter actually derives. A primary adapter has no owner and an unstamped source, so it resolves exactly as before; with multiplex_profiles off the resolver returns None and every key is byte-identical to today's. |
||
|
|
f755ed5e90 | fix(gateway): surface multiplex profile failures (OOF-3) | ||
|
|
c127d0c3b1 |
fix(gateway): attribute scoped credential lock conflicts to the owning profile (OOF-3)
Scoped credential locks (Telegram bot token, Discord bot token, etc.) are
machine-global, but the conflict error only reported the holder's PID:
Telegram bot token already in use (PID 559). Stop the other gateway first.
On multi-profile hosts (e.g. hosted instances running 13 profiles), a bare
PID gives the operator no way to tell WHICH profile owns the credential —
the exact failure mode observed on zerocool-9781, where the 'default'
profile was misconfigured with the same bot token as 'lead-gen-outreach'
and logged an unattributable conflict every ~5 minutes (4,602 rows).
Fix:
- acquire_scoped_lock() now stamps a 'profile' label on lock records,
inferred from the process HERMES_HOME (<root>/profiles/<name> layouts,
'default' for the root home). Omitted when not inferable.
- New scoped_lock_owner_label() resolves the owning profile from a lock
record: prefers the explicit field, falls back to inferring from the
persisted hermes_home for locks written before the field existed.
Labels are validated against the profile-id grammar before use (lock
files are plain JSON on disk and the label flows into log lines and a
suggested CLI command).
- _acquire_platform_lock() conflict message now names the owning profile
and gives the correct remedy:
Telegram bot token already in use by the 'lead-gen-outreach' profile
gateway (PID 559). Stop that gateway first
(hermes --profile lead-gen-outreach gateway stop).
Records with no attribution signal keep the original PID-only wording.
Testing:
- New TestScopedLockOwnerLabel suite covering label inference (named,
Docker, root/default, unknown layouts), grammar validation, explicit-
field preference, hermes_home fallback, and legacy/malformed records.
- acquire_scoped_lock tests for profile stamping and omission.
- Adapter-level tests for profile-attributed, legacy-home-inferred, and
PID-only conflict messages.
- 76/76 targeted gateway tests pass; broad gateway suite failures are
baseline-identical (verified via git stash comparison). Ruff clean.
|
||
|
|
8dc427949f |
fix(gateway): accept CJK full-width punctuation as MEDIA path terminators
MEDIA_TAG_CLEANUP_RE (and MEDIA_EXTENSIONLESS_TAG_RE) only recognized ASCII terminators after a MEDIA:<path> tag. Chinese-language agent output naturally writes MEDIA:D:\...\zhibao.pdf(782.6 KB)or ...pdf:内容 — the full-width punctuation failed the trailing lookahead and the attachment was silently dropped (cron even reported 'delivered') (#88038). Both lookaheads now accept a CJK full-width terminator set (()〈〉《》:,。; !?、curly quotes【】) alongside the ASCII set. The #68773 adjacent-tag splitting guard is covered by a regression test. |
||
|
|
b7936c892d |
fix(gateway): catch BaseException in _process_message_background to notify on SystemExit
The fire-and-forget handler only caught asyncio.CancelledError and Exception, so a SystemExit/KeyboardInterrupt escaping a turn (e.g. a plugin calling sys.exit() in a tool call or summary-LLM path) skipped the user-facing failure notification and surfaced only as 'Task exception was never retrieved' — radio silence for the user. Catch BaseException instead; send the failure notification first, then re-raise SystemExit/KeyboardInterrupt to preserve shutdown semantics for the loop's own signal handling. Other BaseExceptions stay contained. Closes #86651 |
||
|
|
525ca9ca1c |
fix(gateway): match the profile-namespaced session key in the clarify bypass lookup
Fixes #82975. The adapter-level clarify reply bypass in gateway/platforms/base.py's handle_message() built its session_key via build_session_key(...) without a profile= argument, defaulting to the legacy agent:main namespace. The runner registers pending clarifies under SessionStore._generate_session_key()'s key, which DOES include profile=self._resolve_profile_for_key(source). Under a named-profile multiplex these diverge, so the bypass lookup at clarify_gateway.get_pending_for_session(session_key, ...) misses -- the user's answer to a pending clarify() gets routed to the adapter's busy-session queue instead of resolving it. The turn then hangs until the clarify's 3600s timeout, with no inbound message: log line and no "Gateway intercepted clarify text response" log line, matching the reported Telegram symptom exactly. Verified the divergence directly: _resolve_profile_for_key() returns None when multiplex_profiles is off (default) -- byte-identical to the prior implicit profile=None, so this only changes behavior for multiplexed deployments, matching the issue's exact reported scope. Fixed by using the same self._session_store._resolve_profile_for_key() the runner's key generator calls, guarded with getattr() + a None fallback since _session_store is set via a setter and can be unset for adapters that never call set_session_store() -- preserving prior behavior for any such adapter rather than introducing a new crash. Added a regression test alongside the existing bypass coverage: with a mocked session_store configured for profile multiplexing, a clarify registered under the profile-namespaced key must still be found and resolved (not routed to the busy queue). Verified as a genuine regression by reverting the fix and confirming the new test fails with the exact reported symptom (the message handler never gets awaited -- the clarify lookup misses). 21/21 pass across the five directly related clarify test files; 16/16 across the broader multiplex/clarify-progress test files (no regression). |
||
|
|
e4aeb65599 |
feat(webhook): per-route toolset overrides for webhook agent runs
Webhook agent runs default to the constrained hermes-webhook toolset
(web/vision/clarify) because payloads can carry untrusted third-party
content. That default is right for public webhooks but wrong for trusted
local pushes (e.g. an OOM monitor daemon that needs the agent to run
ps/free/py-spy): the only workaround was widening platform_toolsets.webhook,
which elevates EVERY webhook route at once.
This adds a 'toolsets' key on individual webhook route configs (static
routes in config.yaml and dynamic subscriptions in
webhook_subscriptions.json) that replaces the platform-level resolution
for that route only:
- BasePlatformAdapter.toolsets_for_source(): per-source override hook,
default None (no behavior change for any other platform).
- WebhookAdapter.toolsets_for_source(): maps the session chat_id
(webhook:{route}:{delivery_id}) back to its route config and returns
the route's toolsets list.
- GatewayRunner._resolve_enabled_toolsets_for_source(): shared resolver
used by both agent-run call sites; validates the override through the
SAME _get_platform_tools path as platform config, so unknown names and
platform-restricted toolsets (e.g. discord_admin) are dropped rather
than trusted.
Deliberately NOT exposed via 'hermes webhook subscribe': granting elevated
tools is a manual config edit only, so an agent-created subscription
cannot self-grant terminal at runtime.
Cache-safe: the toolset list is resolved before agent construction and is
constant for a route, so the per-session agent signature and frozen system
prompt are unaffected mid-conversation.
|
||
|
|
e64fb2b614 | fix(review): harden plugin gateway injection | ||
|
|
9994bc9ec9 |
fix(gateway): enforce post-auth normalized reaction observer
Builds on Paolo Antinori's #68431 salvage for #64176. Move plugin dispatch behind the profile-scoped runner authorization boundary, fail closed on malformed reaction identities, preserve observer registration across Telegram app rebuilds, and document the deliberately observer-only contract. Co-authored-by: Paolo Antinori <pantinor@redhat.com> |
||
|
|
929be4d1aa |
feat(plugins): gateway_platform_event observer hook (normalized envelopes)
First slice of #64176's observer-hook half — a normalized-envelope inbound event hook, replacing raw-SDK handler args with a stable contract (per #64176's "normalized versioned envelopes only; raw SDK gated behind a capability" rule). - VALID_HOOKS: register the four gateway_* names from #64176 (gateway_platform_event fires today; gateway_session_titled / gateway_message_delivered / gateway_thread_created reserved pending #64176's fire-sites). - BasePlatformAdapter._fire_gateway_hook: reusable, has_hook-guarded, per-call-isolated fire helper (the no-subscriber common case short-circuits). - TelegramAdapter: a group-99 catch-all TypeHandler normalizes inbound updates into gateway_platform_event envelopes. message_reaction -> {platform, event_type:"reaction", payload{emojis, custom_emoji_ids, chat_id, message_id, thread_id}} (custom-emoji reactions captured via custom_emoji_id; standard via .emoji — no None in consumer-facing lists). Other update types return None pending #64176's taxonomy (#64231). Normalization is wrapped so a malformed update can't raise into PTB dispatch. Observer-only — zero behavioral change to core dispatch. Supersedes the raw inbound half of #62584 (telegram:update -> normalized gateway_platform_event). Tests: VALID_HOOKS registration; _fire_gateway_hook routing/has_hook/isolation; _normalize for standard, custom-emoji, and mixed reactions + non-reaction; _on_platform_update firing + normalize-error isolation. Ran code-review (high) + simplify before pushing. |
||
|
|
c8f235a106 | feat(gateway): allow selective multiplex profile serving | ||
|
|
c9411b72df |
fix: store strong ref to detached fatal handler task to prevent GC
asyncio.ensure_future(result) creates a task with only a weak ref in the event loop's task table. After the carrier raises CancelledError, the local 'task' variable goes out of scope and the loop can GC the handler before it finishes — the exact 'handler killed mid-flight' class we are fixing, just via GC instead of cancellation. Add _detached_fatal_tasks set on BasePlatformAdapter (matching the gateway-level pattern in _handle_adapter_fatal_error). Uses getattr fallback for test stubs built via object.__new__(). |
||
|
|
7bc81c4ffd |
fix(gateway): shield fatal-error handler from carrier task cancellation
When an adapter escalates a retryable fatal error from inside one of its own tasks (e.g. Telegram's _polling_error_task after exhausting polling network retries), the gateway's _handle_adapter_fatal_error tears the adapter down via disconnect() — which cancels that very task. The propagating CancelledError killed the handler between popping the adapter from the adapter map and queueing the platform in _failed_platforms, leaving a zombie gateway: process alive, zero connected platforms, zero pending retries, until a manual restart. Run the handler as a detached task under asyncio.shield so carrier cancellation no longer aborts teardown/queueing mid-flight. The carrier still observes CancelledError (teardown semantics unchanged); only the handler is protected. A done-callback consumes the detached task's exception to avoid 'Task exception was never retrieved' noise. Fixes #81335 |
||
|
|
238351a60c |
fix(gateway): widen container->host media translation to home, cache, and in-process gateways
Follow-ups on the salvaged commit (#37207 by @charzhou): - Persistent /root home mount translates too: an agent writing /root/out.png produced a real host file under <sandbox>/docker/default/home the gateway could not find. - /root/.hermes cache mounts translate to the HOST cache (longest-prefix beats the home mount), so MEDIA:<agent_visible_image> paths deliver. - /root/.hermes/* OUTSIDE a cache mount never translates through the home mount: those are the sandbox's credential copies (.env, auth.json) that sit outside the host-side denylist prefixes — fail closed. - Run the idempotent terminal-config->env bridge before mount parsing so in-process gateways (Desktop backend, hermes serve) see the active backend and docker_volumes (covers #42299's /output case there too). |
||
|
|
a7dd885439 |
fix(gateway): support Docker /workspace media paths in gateway delivery
Translate MEDIA paths under configured Docker volume mounts (and the default persistent /workspace) to host paths before media delivery validation, using longest container-prefix match so host:/workspace and /output export mounts work. |
||
|
|
73997c41bb |
fix(tts): split long speech by provider and platform limits
Salvage of PR #17973 by @TKCen (Sebastian Hänisch), re-implemented on current main to preserve speed/instructions/provider params, prepare_spoken_text normalization, OPUS_VOICE_PLATFORMS, is_write_denied path security, microsecond timestamps, and the streaming-TTS gate. - Split long TTS text into provider-safe chunks instead of truncating - Pack generated audio against platform upload limits (Discord 10MB, Telegram 50MB, configurable via tts.delivery_profiles) - Combine chunks with ffmpeg (OGG/Opus re-encoded, MP3 stream-copied) - Multi-file delivery when combination fails or would exceed limits - Remove hard [:4000] truncation from all callers (cli.py, voice.py, gateway/run.py, gateway/platforms/base.py) - Gemini TTS raises ValueError instead of silently truncating when composed prompt exceeds the provider limit Simplify-code fixes: removed dead all_touched_paths set, added try/finally for scratch file cleanup on exception, clean error response on chunk failure instead of leaking stale file_path. |
||
|
|
643910afe3 |
refactor(gateway): narrow worker-start guard to Exception
Thread.start() failure is RuntimeError; catching BaseException here swallowed KeyboardInterrupt/SystemExit without re-raise (unlike _worker, which forwards them into the future). |
||
|
|
38cd1999cb | fix(gateway): fail open + release admission slot when history-lookup worker cannot start | ||
|
|
271867f6fa | fix(gateway): bound media history workers | ||
|
|
e52acf76a1 | fix(gateway): keep media history reads off event loop | ||
|
|
e245a98781 |
fix(matrix): propagate sender MXID + reply context to MessageEvent
The Matrix adapter built MessageEvent from inbound room events but dropped
the sender's MXID and display name on the event itself -- only 'source'
carried them. Other adapters (signal/slack/telegram/discord/mattermost/irc)
have the same gap; this PR fixes matrix and adds the supporting
top-level MessageEvent fields so the rest can follow.
Downstream effects for matrix specifically:
- gateway prompt assembly can now read event.user_name (or source)
without having to dig into source per platform
- reply context (reply_to_text / reply_to_author_id /
reply_to_author_name) is parsed from the inline > <@user:server> ...
Matrix fallback format before stripping, instead of discarded
- the gateway's existing [Replying to: "..."] renderer can now show
who the user was replying to (was always anonymous for matrix)
MessageEvent gains two optional top-level fields (user_id, user_name,
both default None) so non-IM producers (cron/webhook/autonomous) remain
unaffected. Source still carries the same values for callers that
already read from there.
Tests cover:
- non-reply message carries sender user_id/user_name on MessageEvent
- different senders (alice, bob) both propagate
- reply message carries reply_to_message_id + reply_to_text +
reply_to_author_id + reply_to_author_name, parsed from the
> <@carol:example.org> original question\n\nactual reply shape
- non-reply message does NOT spuriously set reply_to_* fields
Sibling matrix tests (148 across test_matrix*.py) remain green.
Authored by WintleChoung <cwt@users.noreply.github.com>
Salvaged from PR #80293.
|
||
|
|
5b36d64583 |
test: raise blocking-probe timeouts for loaded CI runners
CI slices failed the offload tests with 0.5s witness timeouts: on a loaded shared runner the event loop thread can take >0.5s to get scheduled even when NOT blocked, making the probe report a false positive. A genuinely blocked loop can never set the progress event at any timeout (the witness coroutine can't run at all), so 5s only absorbs scheduler flake without weakening the invariant. Mutation re-verified: reverting the offload still fails all 4 tests. |
||
|
|
b7e3cc37be |
test: move the redelivery event-loop test to the class that has its helpers
The sweep-path test parametrizes over _runner/_adapter, which live on TestGatewayRedeliverySweep; main later added TestUnconnectedPlatformKeepsItsBudget at the cherry-pick anchor point and the test landed in that class, where the helpers don't exist (AttributeError x2). Placement-only move. |
||
|
|
498800a22e | fix(gateway): offload delivery ledger I/O | ||
|
|
911d8dfbf4 | refactor(discord): simplify tool preview links | ||
|
|
df9e039d2d | fix(discord): preserve links in truncated tool previews | ||
|
|
d080dc24a8 | fix(gateway): skip topic recovery for non-DM messages | ||
|
|
646761c783 |
fix(gateway): widen explicit-MEDIA resend fix to the streaming path + log bare-path suppression
Companion to the cherry-picked #74158 fix (non-streaming path): - gateway/run.py: remove the identical history-dedup filter from _deliver_media_from_response — the post-stream rescan is explicit-only by design (#20834), so every MEDIA tag it finds is a deliberate attachment request; drop the now-unused history_media_paths parameter and its call-site plumbing. - gateway/platforms/base.py: log suppressed bare local file paths on the surviving local_files history dedup (#73771 observability ask). - tests: focused regression file covering explicit resend delivery on both lanes, current-turn tool-echo poisoning, surviving bare-path dedup + its log line, and the upstream auto-append dedup invariant. Fixes #73771 |
||
|
|
7a83f44c4a |
fix(gateway): preserve explicit send-image requests from session-wide MEDIA dedup
Closes #73771 The session-wide MEDIA dedup in (base.py) filtered ALL media paths against prior-turn history, including explicit MEDIA: tags the model deliberately included in its response text. When a user asked the agent to resend an image, the dedup silently swallowed it because that path already existed in the session transcript. The dedup is already handled correctly by the auto-append path in (run.py), which scopes its scan to the current turn and filters against via . The base.py filter was redundant for auto-appended tags and harmful for explicit ones. Fix: remove the dedup filter on in base.py while preserving the variable for the dedup (bare file paths, which lack run.py protection). |
||
|
|
d26983e485 |
fix(gateway): relay TTS attachments + semantic auto-thread rename on the title turn (#74482)
Two relay-lane bugs from live Discord staging testing (2026-07-29): 1. TTS audio never attached over relay (any platform, any lane). _history_media_paths_for_session excluded only the trailing assistant entry from the persisted transcript when building the delivered-media dedup set. The agent persists rows as it produces them, so THIS turn's text_to_speech tool result (media_tag JSON) was already in the transcript at delivery time — the fresh TTS path deduped against itself and extract_media's attachment was silently stripped (response_delivery_dropped for a MEDIA-tag-only reply; fly logs show the exact signature). Fix: exclude everything from the last USER message onward (the current turn); prior-turn dedup unchanged. Affects every platform adapter (native + relay) on the non-streaming delivery path — the streaming path passes explicit history and was unaffected. 2. Connector-auto-created threads never got the LLM session-title rename. The title fires on the FIRST exchange, whose source is the PARENT channel event — the thread didn't exist at ingest, so the Phase 4 auto-thread markers can't be present and _is_discord_auto_thread_lane never matches on the relay title turn (initial titles worked; semantic renames never happened; staging telemetry shows zero thread_rename ops ever sent). Fix: consume the connector's new send-result feedback (paired gateway-gateway PR — contract §SendResult thread_id/auto_thread_name, additive): RelayAdapter.send() caches (thread_id, initial_name) per chat (bounded 256), run.py's title-callback registration + rename lane read it back and pass initial_name as only_if_current_name so the human-rename-wins guard holds on the relay lane too. Native marker path unchanged; connectors that don't stamp the fields degrade to exactly the old behavior. Tests: 3 new (send-result feedback capture, absence, bound) in test_relay_threads.py; 3 new in test_history_media_current_turn.py (current-turn TTS not deduped, prior-turn still deduped, no-user-row fallback). Relay suite 144 passed. |
||
|
|
5b751dc0ad |
chore: remove unused imports and dead locals (ruff F401/F841 sweep)
Cleans F401 unused imports and F841 dead local assignments across root *.py, agent/, hermes_cli/, tools/, gateway/, cron/, tui_gateway/ (tests/, plugins/, skills/ excluded). Intentionally KEPT (false positives / test-patch surfaces): - agent/transports/__init__.py package re-exports - cli.py browser_connect re-exports (DEFAULT_BROWSER_CDP_URL area, used by tests/cli/test_cli_browser_connect.py) - hermes_cli/main.py _prompt_auth_credentials_choice / _model_flow_bedrock_api_key (accessed via main_mod attr in tests) - gateway/run.py aliased replay_cleanup + whatsapp_identity re-exports and _PORT_BINDING_PLATFORM_VALUES (test-referenced) - hermes_cli/web_server.py get_running_pid (tests monkeypatch it) and _OAUTH_TOKEN_URL availability probe - hermes_cli/config.py get_process_hermes_home re-export (noqa'd F811 chain) and yaml availability-probe import - hermes_cli/nous_subscription.py managed_nous_tools_enabled (tests patch hermes_cli.nous_subscription.managed_nous_tools_enabled) - try/except ImportError availability probes (env_loader, tts_tool, mcp_tool, web_server anthropic OAuth block) - tools/web_tools.py noqa F401 re-exports - hermes_cli/setup_whatsapp_cloud.py:263 'proceed' skipped: possible missing-guard bug, flagged for separate review - unused function parameters (signature changes out of scope) Side-effect RHS calls preserved where only the binding was dead (e.g. web_server proc = _spawn_hermes_action -> bare call). |
||
|
|
c2eda92fd0 |
perf(config): stop deepcopying config on per-turn read-only paths
Four hot-path consumers paid a full config deepcopy per read: - telemetry gate relay_shared_metrics.enabled() — runs 2-3x per agent turn (2x per API call from lifecycle hooks + 1x per tool call) and called read_raw_config(), which deepcopies the whole raw config every call. New read_raw_config_readonly() serves the cached dict directly: 248 us -> 4.6 us per call (54x) on Teknium's real 77-key config. - interruptible_streaming_api_call local-endpoint stale-timeout branch called load_config() once per API call for every local-model user. - gateway get_inbound_media_max_bytes() + _get_ephemeral_system_ttl_default() called load_config() on per-message paths. All three switched to load_config_readonly() (345 us -> 12 us; PR #28866 lineage). Together these account for ~90% of the ~1,900 deepcopy primitives per turn measured in the 26-call stubbed-LLM profile. read_raw_config_readonly() keeps the (mtime_ns, size) freshness key so config edits are picked up next call, and preserves the identity invariant (cache-miss returns the same object later hits serve) — regression-tested with 'is', per the PR #28866 identity-bug lesson. The mutable read_raw_config() is unchanged for save-path callers. 581 targeted tests green (config, relay metrics x2, ephemeral reply, platform base, new readonly suite). |
||
|
|
1f45ff9e8a |
refactor(gateway): shared exec-approval/picker formatting cores in base adapter
- base._format_exec_approval(command, description, smart_denied): shared header/fence/reason/smart-deny assembly driven by _EA_* template attrs and an _ea_escape() hook; base._format_choice_page(options, page, per_page): shared pagination core returning (page_options, meta) incl. the ' (N-M of T)' page_info suffix; base._truncate_preview: the shared truncate-with-ellipsis idiom. - telegram (HTML attrs + _html.escape hook), feishu (card markdown attrs), matrix (head-only; local reaction-legend tail) rewired; telegram's provider/model keyboard pagination and slash-confirm preview use the shared cores. All user-visible strings byte-identical (parity-tested). - slack/discord/teams left untouched: their formatting interleaves platform-specific budget arithmetic (Slack 3000-char section budget subtraction, Discord mention-prefix + dual content/embed budgets, Teams adaptive-card blocks) beyond template params. - tests/gateway/test_interactive_prompt_base.py covers the cores + parity. |