Commit graph

2195 commits

Author SHA1 Message Date
Ben Barclay
40eebc7d70
feat(relay): Phase 4 thread lifecycle — handoff threads, semantic renames, reply_to context, hello command manifest (#71624)
- RelayAdapter.create_handoff_thread → one op-gated thread_create op
  (Discord channel thread / Telegram forum topic / Slack named seed root);
  None fallback contract preserved for the handoff watcher.
- RelayAdapter.rename_thread → thread_rename with only_if_current_name
  no-clobber guard on the wire (connector enforces; Telegram guarded
  renames fail safe). The native semantic-rename lane
  (_is_discord_auto_thread_lane) lights over the relay via the
  connector-stamped auto_thread_created/auto_thread_initial_name markers
  parsed onto SessionSource in _event_from_wire.
- reply_to {text, author, is_own} wire parse onto the SAME MessageEvent
  reply-context fields native adapters populate.
- gateway/relay/command_manifest.py: the gateway-declared slash-command
  manifest (native Discord tree mirror) sent on the DISCORD hello; the
  connector reconciles Discord's global registration (additive field,
  older connectors ignore).
- Contract doc §OutboundAction ops + Phase 4 semantics sections.
- 12 new tests (tests/gateway/relay/test_relay_threads.py); relay suite
  266 passed.
2026-07-26 10:26:20 +10:00
Ben Barclay
fffa661223
feat(relay): Phase 3 interactive — native prompt UX (approvals/confirms/clarify) + react ack lifecycle (#71404)
- RelayAdapter.send_exec_approval / send_slash_confirm / send_clarify:
  override the base text fallbacks with ONE platform-abstract `prompt` op
  (connector renders Discord components / Telegram inline keyboards /
  Slack Block Kit / WhatsApp buttons+lists). Option sets mirror the native
  adapters exactly (once/session/always/deny with the same
  allow_session/allow_permanent/smart_denied gating; once/always/cancel;
  choices + Other). Clarify option ids are positional (c0..cN/other) —
  choice text is arbitrary UTF-8, callback budgets are 64 bytes.
- Pending-prompt registry: gateway-minted 8-hex prompt ids →
  {kind, session_key, extras}; one answer wins, lazy expiry, unanswered
  prompts swept opportunistically. Wire timeout_s stays advisory.
- _consume_prompt_response (wired into _on_inbound AND the Discord
  passthrough lane): routes answers to the SAME primitives the native
  button handlers call — tools.approval.resolve_gateway_approval,
  tools.slash_confirm.resolve, tools.clarify_gateway
  resolve/mark_awaiting_text — then acks in-channel. Unknown/expired ids
  fall through as command-shaped text (typed-reply degradation, the
  relay's analog of the native 'approval expired' edit).
- Discord type-3 stub replaced: an hp1:<prompt>:<option> custom_id decodes
  to a structured prompt_response (codec mirrored from the connector's
  promptCodec); foreign custom_ids keep the legacy best-effort text shape.
- MessageEvent.prompt_response field + ws_transport wire parsing (additive).
- react ack lifecycle: on_processing_start/complete → `react` ops
  (👀/, remove-then-add), op-gated on supported_ops, best-effort by
  contract (a react failure never touches the turn).
- Op gating throughout: a connector not advertising `prompt` gets
  success=False from send_exec_approval/send_slash_confirm (run.py's text
  fallback takes over — same contract as a failed native button send) and
  the base numbered-text clarify; `react` silently no-ops.
- docs/relay-connector-contract.md §4: prompt / prompt_response / react
  semantics (callback token, budgets, authorization-parity, foreign-id
  behavior, per-platform react mappings).
- tests: tests/gateway/relay/test_relay_interactive.py (19) — option-set
  rendering + gating matrices, registry consume-once/expiry, resolver
  routing for all three kinds (monkeypatched primitives), fall-through
  cases, Discord hp1 decode + foreign-id shape, react lifecycle
  (success/failure/cancelled), op-gated/best-effort react.

Cross-repo pair: gateway-gateway 'Phase 3 interactive' PR (prompt/react
senders on all four lanes + interaction ingest).
2026-07-26 07:57:39 +10:00
Teknium
32fd9d65cf feat(compression): progress-aware timeouts — stop punishing slow summary models
The gateway's pre-agent session-hygiene compression killed the summary
call at a fixed 30s wall-clock deadline (compression.hygiene_timeout_seconds),
regardless of whether the summary model was hung or merely slow. A reasoning
model happily streaming a large summary was cut off mid-generation, the user
got '⚠️ Context compression timed out after 30.0s', and a 300s failure
cooldown left the session oversized — a doom loop for slow-but-healthy
auxiliary models.

Timeouts are now liveness-based instead of wall-clock-based:

- agent/auxiliary_client.py: new thread-local aux_progress_hook. When
  installed (only by context compression today), the primary call_llm
  attempt streams (stream=True) and aggregates chunks back into a complete
  response, ticking the hook per chunk. The configured timeout then acts
  per stream read (idle) instead of as a total budget. Providers that
  reject streaming fall back to the plain non-streaming call; auth/payment/
  rate-limit/transport errors propagate unchanged into the existing
  recovery chains. Codex Responses (per SSE event) and Anthropic Messages
  (per stream event, via the new create_anthropic_message on_stream_event
  callback) tick the same hook from inside their wire adapters.

- agent/conversation_compression.py: CompressionCommitFence gains
  touch_progress()/seconds_since_progress(); compress_context() installs
  fence.touch_progress as the progress hook around the compress call.

- gateway/run.py: the hygiene wait loop treats hygiene_timeout_seconds as
  an INACTIVITY budget — while the fence reports fresh progress the wait
  extends, bounded by the new compression.hygiene_total_ceiling_seconds
  (default 600s, clamped >= the idle budget) so a degenerate trickle
  stream still dies. The timeout warning now says the summary model
  produced no output, which is the only case that still triggers it.

- config/docs: hygiene_total_ceiling_seconds added to DEFAULT_CONFIG and
  configuration.md; hygiene_timeout_seconds documented as inactivity-based.

Tests: tests/agent/test_aux_progress_streaming.py (hook plumbing, stream
aggregation incl. tool-call deltas and reasoning deltas, rejection
fallback, ceiling kill, fence progress surface); two new gateway tests
prove a slow-but-streaming worker survives past the fixed timeout
(sabotage-verified: fails with the old fixed deadline) and a
forever-trickling worker is still cut off at the ceiling.
2026-07-25 12:26:28 -07:00
Ben Barclay
689b51bef6
feat(relay): Phase 2 media parity — send_media egress + inbound media localization (#71363)
- gateway/relay/media.py: RelayMediaClient for the connector's /relay/media
  plane (upload local files → re-host reference for send_media; download
  re-hosted inbound attachments → local temp paths). Same connector base URL
  the WS dials, same per-gateway signed bearer as the upgrade (auth.py) —
  no new configuration. stdlib urllib in a thread executor (no new deps);
  25MB cap mirroring the connector's MEDIA_MAX_BYTES.
- RelayAdapter: send_image / send_image_file / send_voice / send_video /
  send_document overrides route through ONE send_media op (media by
  reference: local paths upload first, public URLs pass through). Gated on
  supported_ops advertising send_media — legacy connectors keep today's
  text fallbacks; connector declines/failed uploads degrade the same way.
  Scope/user egress discriminators ride metadata exactly like send.
- Inbound: _localize_inbound_media downloads each media_urls entry to a
  local temp path (native-adapter parity — vision/file tools consume
  paths); dead re-host refs are dropped, public URLs survive a missing
  client. Best-effort, never blocks handle_message.
- docs/relay-connector-contract.md §4: send_media op row + media
  ingress/egress semantics (replaces the 'deferred to a later revision'
  note). Additive within contract_version 1.
- tests: tests/gateway/relay/test_relay_media.py (15) — kind mapping,
  upload-first path handling, op gating (explicit + legacy-empty),
  decline/upload-failure fallbacks, scope metadata, inbound localization
  matrix, client URL derivation/credential gating. Stub connector grew a
  canned send_media result.

Cross-repo pair: gateway-gateway 'Phase 2 media parity' PR (re-host plane +
four ingress lanes + four platform send_media senders).
2026-07-25 23:15:52 +10:00
kshitijk4poor
6ad632bf9b test(telegram): cover smart_deny 2-button row structure
Follow-up for salvaged PR #70615 — the 2-button smart_deny case
(Allow Once + Deny only) was exercised by an existing test but only
at the flat-label level, not asserting the row pairing. Adds the
missing row-structure assertion using the same capture pattern as
the 4-button and 3-button tests.
2026-07-25 17:51:06 +05:30
HexLab98
116a44f46e test(telegram): cover exec-approval keyboard row pairing
Assert the full set renders as 2x2 and the three-button case keeps Deny on
its own second row.
2026-07-25 17:51:06 +05:30
Ben Barclay
ebab890ae5
feat(relay): Phase 1 parity — supported_ops discovery, wire identity fields, /handoff aliasing, provision displayName (#71300)
- CapabilityDescriptor.supported_ops + supports_op() with legacy-op-set
  fallback (additive, contract doc §2 updated)
- get_chat_info gated on op discovery (skip round trip on legacy connectors)
- _event_from_wire consumes user_display_name/user_handle (§3 fields
  previously dropped); native display-name parity, session-key stable
- /handoff <fronted-platform> works on relay-fronted gateways: CLI pre-check
  reads the GATEWAY_RELAY_PLATFORMS fronted set; watcher resolves through
  resolve_delivery_transport and replies via send_for_platform
- self-provision forwards displayName (env > skin branding, stock brand
  suppressed) — the primary name source for gg#171 attribution
2026-07-25 20:36:16 +10:00
Ben Barclay
e0dfcf275a
fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048)
A real APPLICATION_COMMAND interaction forwarded over the relay arrived
slash-less: _discord_interaction_to_event set text = data['name'] ("new",
not "/new"), MessageType.TEXT, and dropped options entirely — so a
registered /new dispatched as plain chat instead of a command
(MessageEvent.is_command() is text.startswith("/")).

Port the connector's Slack slash-command precedent (normalizeSlackCommand
builds `${command} ${args}`.trim() with a leading slash and explicit
command type): for type-2 interactions build "/" + name, append rendered
options space-separated (scalar options contribute their value, matching
the native adapter's f"/model {name}" shape; SUB_COMMAND/
SUB_COMMAND_GROUP contribute their name then recurse into nested
options), and set MessageType.COMMAND. Type-3 (custom_id) and other
interaction types are unchanged. This implements the interaction->command
sub-design previously flagged as deferred in the _on_passthrough
docstring.

Companion connector fix in gateway-gateway: fix(relay): strip own-mention
prefix so addressed slash commands dispatch.
2026-07-25 13:29:36 +10:00
teknium1
9ac8b24fd5 test: record getUpdates progress in mocked cold-connect polling flows
The strict cold-start readiness gate (#67498) means adapter.connect() no
longer returns True until the mocked start_polling records a successful
getUpdates round trip for its generation. Update the conflict-suite
Application mocks accordingly:

- fake_start_polling side effects call
  adapter._record_polling_progress(adapter._polling_generation) on the
  initial connect (retry generations intentionally do NOT auto-progress
  where a test asserts the conflict count survives an unproven retry).
- _build_polling_app takes the adapter so its start_polling mock can
  record progress.

Without this, the cold connects in these tests wait out the full 60s
readiness deadline and fail — which is exactly the fail-closed behavior
the gate is supposed to provide when polling shows no progress.
2026-07-24 19:11:04 -07:00
teknium1
c8ff720508 fix(telegram): bind strict cold-start readiness to its own polling generation
Follow-up hardening for the salvaged #69240 readiness gate (#67498):

- _start_polling_once now returns its (generation, progress_event) pair
  so the strict cold-start gate binds to exactly the generation it
  started, instead of re-reading self._polling_progress_event which a
  concurrent recovery task may have replaced with a newer generation's
  event (the G1/G2 race flagged in the #69240 review).
- Strict cold start no longer schedules background polling recovery: a
  polling error during the readiness wait is captured by a strict
  callback and fails the connect attempt immediately with a loud
  OSError, so GatewayRunner disposes the partial adapter and retries
  with a fresh one — no more waiting out the full readiness deadline on
  a generation that already errored, and no G2-on-partial-app healing.
- After readiness is proven the strict callback delegates every later
  polling error to the real background-recovery callback, preserving
  the existing degraded/reconnect semantics for the polling lifetime.
- The readiness-timeout error message now states the deadline and that
  the gateway will retry with a fresh adapter (loud failure, not a
  silent wait).
- Regression tests: current-generation progress connects; a polling
  error during strict cold start fails fast without scheduling
  background recovery (the #67498 idle-threads shape); stale-generation
  progress is rejected.

Progresses #67498
2026-07-24 19:11:04 -07:00
mannnrachman
9f8810a693 fix(gateway): allow Telegram readiness budget
Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498
2026-07-24 19:11:04 -07:00
mannnrachman
83e30e371f fix(telegram): require initial polling readiness
Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498
2026-07-24 19:11:04 -07:00
Hermes Agent
9cd7296849 refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs
Follow-up to the salvaged #69164 commits: policy forbids introducing new
HERMES_* environment variables, so the four watchdog env knobs
(HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are
replaced with a single config.yaml boolean:

  gateway:
    loop_watchdog: true   # default; false disables both guards

- gateway/config.py: new GatewayConfig.loop_watchdog field (default True),
  parsed from top-level or nested gateway: form, round-trips via
  to_dict/from_dict.
- gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog
  before arming the floor timer + watchdog (getattr-guarded for bare
  object.__new__ runners).
- gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer
  reads the environment; probe interval/timeout/strikes are module
  constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog
  layer's posture).
- hermes_cli/config.py: documented gateway.loop_watchdog default so
  'hermes config set gateway.loop_watchdog false' validates.
- tests: env-knob tests replaced with config-gate + round-trip tests;
  the final-strike boundary test injects its probe via max_strikes
  directly instead of patching the removed env helper.
2026-07-24 16:03:42 -07:00
Josh Tsai
df03120cb6 fix(gateway): recheck stop immediately before watchdog hard exit
- A stop() landing while the final diagnostics (critical log,
  traceback dump) are executing could still reach os._exit(75) after
  the pre-diagnostic check. Add a third stop_event recheck immediately
  before the hard exit: diagnostics may complete, but a disarmed
  watchdog never exits.
- Deterministic regressions for both windows (stop triggered from
  inside logger.critical and from inside faulthandler.dump_traceback);
  mutation-verified (removing the check turns both red). Frozen-loop
  semantics unchanged.

Addresses the second round of the shutdown-race review on #69164.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
Josh Tsai
aff415b653 fix(gateway): close watchdog shutdown race against final-strike exit
- Re-check stop_event after a missed probe (before the strike
  increment) and again on entering the final-strike branch (before the
  critical log, dump, and hard exit), so a normal stop() landing
  between the last timeout check and the exit path can no longer be
  misclassified as a freeze and trigger a supervisor restart.
- Deterministic boundary tests pin both re-checks independently
  (mutation-verified: removing either check turns its own test red);
  frozen-loop semantics are unchanged.

Addresses the shutdown-race review on #69164.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
Josh Tsai
7af3a6fc7c fix(gateway): detect and escape silent event-loop freezes
- A self-rescheduling 5s call_later floor timer, armed before any
  adapter connects, guarantees the selector always has a finite
  timeout, so the existing async defenses (polling heartbeat, timeout
  guards) regain a chance to run after a zero-pending-timer stall.
- A resident daemon-thread liveness watchdog probes the loop via
  call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout
  misses (~120s of total unresponsiveness) it dumps all thread
  tracebacks and exits with the established
  GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the
  gateway - async-level recovery cannot run on a frozen loop.
- stop() disarms both guards before any teardown await so a busy
  shutdown is never misjudged as a freeze.
  HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES
  tune the thresholds.

Fixes #69089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
Shannon Sands
eb2f5b5723 fix(gateway): stay alive on mixed retryable + non-retryable startup failures
When connected_count == 0 and at least one platform failed with a
non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE
(78) even if OTHER platforms failed for merely transient reasons.

Real-world shape (NS-609, hosted instance): WhatsApp enabled but never
paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during
polling startup (retryable) => exit 78 => the gateway either goes
permanently down (supervisors honoring the exit-78 contract via
RestartPreventExitStatus / the s6 finish->125 translation from #51228) or
crash-loops (anything else). Either way Telegram never gets its retry and
the dashboard drops with every exit, so a single unpaired platform plus
one network blip disconnected every channel on the instance.

Now exit 78 is reserved for the case where ALL startup failures are
non-retryable (true config error, nothing to wait for). With mixed
failures the gateway stays alive in degraded state: the reconnect watcher
recovers the retryable platforms and the misconfigured ones stay
fatal-parked and visible in runtime status.
2026-07-24 16:03:10 -07:00
webtecnica
83fe362e80 fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344)
Three-part fix for the gateway going silently deaf after a retryable
fatal adapter error (e.g. httpx.ConnectError on Telegram):

1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced
   plain asyncio.wait_for with the task-detach pattern used by
   _await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the
   overdue task but then waits for it to exit, so a connect() that
   catches CancelledError can block recovery forever. The detach
   pattern releases the runner at the deadline via
   consume_detached_task_result.

2. **Ensure reconnect watcher always runs after escalation** — Added
   _ensure_reconnect_watcher_running(), called after queueing a
   retryable fatal error. If the reconnect watcher task has died
   (exhausted restart budget, terminal exception), it is respawned
   so queued platforms are never permanently stranded.

3. **Faulthandler at gateway startup** — Enabled faulthandler +
   SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for
   post-mortem diagnosis of future event-loop freezes.

Tests added for _ensure_reconnect_watcher_running (alive, dead,
not-started, not-running), fatal-error integration (retryable calls
ensure, non-retryable does not), and _connect_adapter_with_timeout
(timeout raises, success returns).
2026-07-24 16:03:10 -07:00
teknium1
3960315af7 test: order compression-tip fixtures around the closed-parent write guard
Two compression-tip hydration tests simulated legacy state by emptying
the parent AFTER end_session(compression) — exactly the durable write
the new closed-parent guard refuses. Reordered: empty first, close
second. The tests' actual contract (old id hydrates from the live tip)
is unchanged and still pinned.
2026-07-24 16:00:34 -07:00
Anthony Ruiz
0ee8d41878 fix(compression): recover rotated session lineage 2026-07-24 16:00:34 -07:00
Dhruv Raajeev
d10d3d7b42 fix(gateway,tools,agent): close leaked SQLite connections in delivery, delegation, and verification ledgers
Three durable ledgers used `with _connect() as conn:` where the sqlite3
connection context manager commits/rolls back but never closes, leaking the
db/-wal/-shm file descriptors on every call. On a long-running gateway this
exhausts RLIMIT_NOFILE and fails unrelated components with
`[Errno 24] Too many open files`. Same bug class as the cron execution ledger
(#69567 / PR #69594), which the connection helpers here are modeled on.

Fix: route every ledger operation through a `_transaction()` context manager
that guarantees `conn.close()` on exit. `_connect()` keeps its
schema-on-connect contract (several tests call it directly) and now self-closes
if schema init fails.

Adds per-module regression tests asserting every opened connection is closed,
including the no-op-update and exception-mid-transaction paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:55:08 -07:00
teknium1
cc6e8fa757 fix(gateway): extend the utf-8 file-I/O guard to google_chat + whatsapp
Follow-up to the salvaged #38985: guard the 4 bare read_text/write_text
sites its allowlist missed (google_chat thread-count store + oauth JSON)
and add whatsapp/google_chat to the AST guard test's file list.
2026-07-24 15:47:12 -07:00
Rod Boev
7b8a4d74f9 fix(gateway): cover discord update-response utf-8 path (#37423) 2026-07-24 15:47:12 -07:00
Rod Boev
5b76ce169b fix(gateway): pass encoding="utf-8" to read_text/write_text in update path (#37423) 2026-07-24 15:47:12 -07:00
abundantbeing
7cd48733db feat(api): backend-acknowledged session model lock with runtime routing
Add a persisted, backend-confirmed provider/model lock for Hermes
Browser and other session API clients. A confirmed lock is an
execution contract rather than response metadata:

- POST /api/sessions/{session_id}/model validates and persists a
  confirmed browser_model_lock (advertised in /v1/capabilities)
- session chat + chat/stream consume the persisted lock on body-only
  follow-up turns; a confirmed lock wins over an older gateway session
  /model override and the session-persisted model
- a later successful session /model switch explicitly clears and
  replaces the lock while preserving lineage markers (_branched_from)
  and invalidating cached system-prompt model/provider metadata
- ordinary one-off request overrides never replace a confirmed lock
- provider-resolution failure fails closed as a typed provider-auth
  error (controlled response, never global-credential reuse)
- confirmed locks disable the global fallback model chain
- the completed agent's actual provider/model must match the locked
  route or the turn fails with a runtime-mismatch error
- responses carry sanitized runtime metadata reporting actual vs
  requested provider/model and lock state

Rebased onto the provider-aware request routing (#70853) and
session-model parity (#70931) that landed since the original branch;
the lock now slots into that precedence chain as the top rung.

Salvaged from PR #61236 by @abundantbeing.
2026-07-24 13:39:21 -07:00
Frederick
7dd00bb47d fix(api_server): close divergence gaps from gateway/run.py
Three parity fixes between the API server and the native gateway's
agent-runtime resolution, integrated with the provider-aware request
routing that landed in #70853:

- Session-persisted model is honored: POST /api/sessions {"model": ...}
  stores a model that the chat handlers previously fetched and threw
  away. A stored value that matches a model_routes alias goes through
  the route path (route provider/credentials apply); a raw model string
  threads through as session_model, pinning the session's turns ahead
  of per-request body values but below an explicit session /model
  override.
- Empty-model recovery: provider-catalog default when config has no
  model.default but a provider resolved, plus last-known-good model
  recovery (#35314) keyed on gateway_session_key only (never ephemeral
  session_id — no unbounded growth from one-off requests).
- Provider auth failures surface as controlled responses: RuntimeError
  from _resolve_runtime_agent_kwargs() is re-raised as a dedicated
  _ProviderAuthResolutionError at the call site, caught narrowly in
  _run_agent() and the /v1/runs executor to return run.py's response
  shape instead of an undifferentiated 500 (session-chat endpoints
  previously returned a raw aiohttp 500 with no JSON body).

Salvaged from PR #57947 by @FvanW; session-model route-alias resolution
from PR #59941 by @kaishi00.

Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
2026-07-24 11:53:46 -07:00
teknium1
804ce793de test: update kwarg-snapshot assertions for the utf-8 subprocess guard
- whatsapp taskkill + webhook gh-comment assert_called_with: add the two
  new kwargs
- test_status fake_run: accept **kwargs so signature-strict stub doesn't
  TypeError on encoding/errors
2026-07-24 11:45:57 -07:00
abundantbeing
4a9447c726 fix(api-server): expose model options inventory
Add authenticated GET /api/model/options to the gateway API server,
sharing the dashboard/TUI picker payload builder so external clients
can sync to the user's configured Hermes provider catalog instead of
scraping the single OpenAI-compatible /v1/models alias.

- new shared hermes_cli.inventory.build_model_options_payload() wraps
  build_models_payload with the stable picker shape and safe
  custom-provider probe policy (probe current only on normal open,
  probe all + cache bust on explicit refresh)
- dashboard web_server and TUI gateway model.options refactored onto
  the shared builder; dashboard build moved off the event loop via
  run_in_threadpool
- capabilities endpoint advertises model_options
- docs for both API server and programmatic integration

Salvaged from PR #54689 by @abundantbeing.
2026-07-24 11:20:07 -07:00
Victor Kyriazakos
45a408f41a fix(gateway): deliver relay-backed homes after restart 2026-07-24 10:45:13 -07:00
teknium1
9f384783e7 fix(api): gate bare-model passthrough + route-alias model leak
Follow-ups on the salvaged #54426 routing contract:

- Bare `model` without `provider` on the OpenAI-compatible endpoints
  (/v1/chat/completions, /v1/responses) is now opt-in via
  gateway.platforms.api_server.direct_model_requests (default off) —
  generic OpenAI clients hardcode model names ('gpt-4o', ...) and
  existing deployments rely on those falling back to the gateway
  default. Explicit `provider` requests and the Hermes-native
  session-chat + /v1/runs surfaces are always honored.
  Idea credit: PR #22825 by @mssteuer.
- A model_routes alias with no `model` key can no longer leak the
  alias string as the executing model name (defensive; parse-time
  validation already drops such routes).
- Fix mis-indented _run_agent call args in _handle_session_chat_stream.
- Docs: document the opt-in flag.
2026-07-24 09:54:08 -07:00
abundantbeing
d66a82000c feat(api): honor provider-aware request routing
Carry model, provider, and model_options through the API server's
execution surfaces (session chat, Chat Completions, Responses, /v1/runs)
without mutating global configuration. Precedence: session /model
override -> model_routes alias -> direct request selection -> global
defaults. Conflicting route/provider mixes fail closed with 400.
model_options stays request-scoped regardless of which selection wins.

Salvaged from PR #54426 by @abundantbeing.
2026-07-24 09:54:08 -07:00
teknium1
397e9fc1e4 Reapply "Merge pull request #30179 from NousResearch/feat/iron-proxy"
This reverts commit c6dc7c03c3.
2026-07-24 09:49:00 -07:00
Brooklyn Nicholson
4cd4b3e8a2 test(gateway): account for the auto-archive construction-time sync escape
The gateway startup maintenance block gained a maybe_auto_archive call in
the same provably-off-loop __init__ site as maybe_auto_prune_and_vacuum;
bump the reviewed sync-escape count from 3 to 4.
2026-07-24 10:45:34 -05:00
Teknium
4025329ac4
feat(gateway): opt-in compression progress notices via compression.progress_notices (#52995) (#70457)
Routine automatic compression stays silent-by-design on chat platforms
(default unchanged, byte-identical). New opt-in config key
compression.progress_notices (bool, default false) opens a gate on the
gateway noise filter (_prepare_gateway_status_message) that lets ROUTINE
compression progress statuses through to chat surfaces.

- Membership is derived from the #69550 status template constants in
  agent/conversation_compression.py (compiled to a literal-escaped regex,
  never re-inlined wording), so unrelated noisy statuses (aux failures,
  provider retry/rate-limit chatter) stay suppressed even when enabled.
- The compaction completion notice (COMPACTION_DONE_STATUS, #69546
  lifecycle 'compacted' edge) already flows through the status path and
  passes the filter — no new emit site needed.
- Config wired everywhere: hermes_cli/config.py DEFAULT_CONFIG,
  cli-config.yaml.example, gateway raw-YAML read (live, mtime-cached),
  gateway hot-reload cache-busting key list, website configuration docs.
- Failure notices and manual /compress feedback remain always-visible;
  VISIBLE_COMPRESSION_MESSAGES and emit sites untouched.

Design by @havok-training (issue #52995).
2026-07-23 19:44:19 -07:00
Teknium
eebc2286fc fix(gateway): retry-next-message semantics for compression_deferred + regression suite
Gateway half of the #49874 salvage: pass compression_deferred through
both _run_agent_inner result dicts and guard the compression-exhausted
auto-reset block with it — a lock-contended defer keeps the session
intact (the concurrent compressor is actively shrinking it) instead of
wiping it via reset_session.

Regression tests:
- tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and
  400-overflow turns whose compression pass lost the lock end as
  compression_deferred (failed=False, no compression_exhausted); flag
  unset keeps the terminal exhaustion path byte-identical; type-pin
  tests vs MagicMock agents and junk flag values; cap=1 e2e proving the
  refunded pre-API defer leaves the budget for the provider-proven
  413 retry.
- tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight
  pass stops the loop WITHOUT arming preflight_compression_blocked;
  plain no-op still arms it; MagicMock junk does not defer.
- tests/gateway/test_compression_deferred_soft_result.py — AST pin that
  the deferred branch guards the auto-reset chain and performs no
  session mutation (mirrors test_35809_auto_reset_clean_context.py).
2026-07-23 16:23:57 -07:00
ethernet
75afaf46da
test(gateway): use valid API server key in env override test (#70274)
Keep the explicit-disable regression test on the usable-key path after
the API server loader began rejecting weak API_SERVER_KEY values.
2026-07-23 19:56:28 +00:00
Teknium
da131aef3a feat(slack): bridge slack.ignored_channels through the YAML→env config path
Follow-up to the #51899 pick, folding in the config-bridge half of the
competing PR #46925 (@bhanusharma, earliest submitter for the
ignored-channel gate):

- _apply_yaml_config: translate config.yaml slack.ignored_channels into
  SLACK_IGNORED_CHANNELS (list or CSV), env-var-wins like every other
  bridged Slack key.
- SlackAdapter._slack_ignored_channels / gateway.run's
  _slack_ignored_channels_from_gateway_config: fall back to the
  SLACK_IGNORED_CHANNELS env var when PlatformConfig.extra carries no
  value, so top-level slack: blocks (which flow through the env bridge,
  not extra) are honored at both the adapter and runner gates.
- conftest: force-clear SLACK_ALLOWED_CHANNELS / SLACK_IGNORED_CHANNELS /
  SLACK_DISABLE_DMS between tests (config-loader side-effect leak class).
- Tests: env-bridge translation + precedence in test_config.py, env
  fallback + extra-wins in test_slack_runner_ignored_channels.py.

Credit: #46925 by @bhanusharma proposed the same gate with the YAML→env
bridge; #51899 (picked as the base) carries the wider outbound/runner
coverage. Closes #46925 as consolidated here with first-submitter credit.
2026-07-23 12:01:24 -07:00
AhmetArif0
805c22c836 fix(streaming): add Slack streaming=false default to match Discord
PR #37303 added per-platform streaming defaults and the commit message
explicitly called out "Discord/Slack/etc. only have edit-based streaming
(repeated editMessage), which flickers and is noticeably jankier" — but
only discord.streaming=false was shipped. Slack uses the same edit-based
streaming mechanism and has the same flicker problem, yet it was left to
follow the global switch (default true when streaming is enabled).

Add "slack": {"streaming": False} to DEFAULT_CONFIG["display"]["platforms"]
alongside the Discord default. The same deep-merge semantics apply: a user
who explicitly sets display.platforms.slack.streaming: true keeps their
value unchanged. The dashboard schema gains a slack.streaming toggle
automatically since it is generated from DEFAULT_CONFIG.

Update test_per_platform_streaming_defaults.py to cover slack in all
existing assertions and rename the resolver test to reflect both platforms.
2026-07-23 12:01:24 -07:00
Michael Brooks
2946805299 feat(slack): set HermesAgent User-Agent on slack-bolt client
Hermes already identifies itself on outbound calls to model providers
and monitoring endpoints — see hermes_cli/models.py and
plugins/model-providers/gmi/__init__.py
("Attribution so GMI can identify traffic from Hermes Agent"). The
Slack adapter is the one outbound surface that doesn't carry the
same identifier, so HermesAgent-driven Slack traffic is
indistinguishable from any other Bolt-Python app at the Slack
platform layer.

This sets `user_agent_prefix=f"HermesAgent/{_HERMES_VERSION}"` on the
AsyncWebClient instances constructed in gateway/platforms/slack.py
and threads them through AsyncApp via its `client` kwarg. Both
kwargs are first-class in slack-sdk and slack-bolt; no new
dependencies. Resulting header looks like:

    HermesAgent/<version> Python/3.x slackclient/3.x ...

No behavioral change for users — the Slack API ignores User-Agent
semantically; it lands in logs and analytics. Reversible.

Tests in tests/gateway/test_slack.py:
- TestSlackUserAgent pins the prefix shape and runs connect()
  end-to-end (multi-token config) to assert every AsyncWebClient
  carries the prefix and AsyncApp receives the pre-built client.
- TestSlackProxyBehavior fakes updated to tolerate the new kwargs
  via **_kwargs so they don't break on future passthroughs.
2026-07-23 12:01:24 -07:00
David Metcalfe
241bc112e8 fix(platforms): clear home channel when setup prompt left blank
Blank (or whitespace-only) answers to the home-channel prompt in the
interactive setup wizards previously left any previously saved
*_HOME_CHANNEL / *_HOME_ROOM env value in place, so operators could not
clear a stale home channel by re-running setup. Strip the prompt input
and call remove_env_value() on blank answers across the Discord, Slack,
Feishu, Matrix, Mattermost, WeCom and WhatsApp plugin setup wizards,
with per-adapter wizard tests covering set/clear/whitespace flows.

Squash of the three commits from PR #58421 (setup-wizard fix, matrix/
wecom extension, and 6-adapter test coverage) — one commit per
contributor on this salvage branch.

Fixes #12423
Salvaged from #58421
2026-07-23 12:01:24 -07:00
vexclawx31
ee62aab1a7 fix(slack): honor disable_dms setting 2026-07-23 12:01:24 -07:00
byshubham
f8f5ce7da5 fix(slack): honor ignored channels before gateway dispatch 2026-07-23 12:01:24 -07:00
Teknium
9439f11717 test(slack): pin download-token workspace routing (#59742)
Five regression tests for _resolve_download_token and the download
helpers: explicit team wins, URL-embedded team id routes to the owning
workspace, unknown/no-match fall back to the primary token, and an
end-to-end _download_slack_file_bytes call asserts the Authorization
header carries the owning workspace's token.

A/B: all five fail with the fix commit reverted, pass with it applied.
2026-07-23 12:00:34 -07:00
Bob
06be0e69b6 fix(gateway): namespace Slack sessions by workspace 2026-07-23 12:00:34 -07:00
Teknium
ed67f9aacc test(slack): adapt main's marker-mechanism tests to workspace-scoped markers
Two tests on main pinned the pre-#20583 bare-ts marker mechanism
(_mentioned_threads entries and _slash_command_contexts key shape).
The salvaged workspace scoping intentionally changes both:

- _mentioned_threads now records (team_id, ts) markers when the event
  carries a team id, so the top-level-mention test asserts the scoped
  tuple.
- _slash_command_contexts keys are (team_id, channel, user) 3-tuples
  when the slash payload includes team_id.

Follow-up to the #20583 cherry-pick (jordanhubbard).
2026-07-23 12:00:34 -07:00
Jordan Hubbard
a60b00e12d fix(slack): isolate workspace-local routing 2026-07-23 12:00:34 -07:00
Teknium
0796a981d7 fix(api_server): bind chat_id (raw session id) on /v1/runs
/v1/runs bound only session_key at its _bind_api_server_session call, so
tools.async_delegation._current_origin_session_id() — which reads the
request-scoped HERMES_SESSION_CHAT_ID — returned "" on that route and
runs-originated background delegations stayed forced-sync with no wake
target. Bind chat_id/session_id the same way the other agent-entry routes
do via _run_agent(). Follow-up to #64998 (sweeper review F2).
2026-07-23 11:55:17 -07:00
Teknium
2cc0ff44b6 fix(kanban): advance notify cursor only after a successful wake self-post
On non-push adapters (api_server) the wake self-post IS the delivery, but
the cursor advanced before the self-post ran and a failed/exhausted post
was swallowed by the best-effort except — permanently losing the event.

Reorder the else-branch: for non-push adapters run the self-post FIRST and
only advance the cursor once it succeeds. A failure rewinds the pre-send
claim (same guarantee as the existing SendResult(success=False) path) so
the next tick retries, with the same MAX_SEND_FAILURES drop threshold.
Push-capable adapters keep the pre-existing advance-then-best-effort-wake
behavior. Follow-up to #64998 (sweeper review F1).
2026-07-23 11:55:17 -07:00
Ian Ker-Seymer
246eacea7b fix(gateway): deliver kanban/delegate wake-ups to api_server sessions
Wake-ups for kanban notifications and background delegation completions were
injected via handle_message() using a build_session_key()-derived key, which
can never match the raw X-Hermes-Session-Id key that api_server sessions run
under — so the wake landed in a session nobody was reading. On top of that,
ApiServerAdapter.send() reports failure without raising, and that was treated
as a successful delivery, so the notify cursor advanced past events that were
permanently lost; and background delegation was forced synchronous on
api_server since there was no way to wake the session afterward.

Fix: route wake-ups for non-push adapters through a self-post to
/v1/chat/completions with the original session id, treat non-raising send
failures as failures (rewind instead of advancing the cursor), and re-enable
background delegation whenever a session id is available to wake.

The origin session id is captured from the request-scoped api_server chat_id
binding rather than HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id() with the subagent's internal id, clobbering that
variable right before dispatch would read it and misrouting the wake into
the subagent's own session.

Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
2026-07-23 11:55:17 -07:00
annguyenNous
32f7c5afaf fix(gateway): distinguish gateway auth 401 from provider API key errors
The api_server adapter returned error code "invalid_api_key" for
API_SERVER_KEY authentication failures, which the Desktop error
classifier misidentified as a provider (OpenRouter/OpenAI) key
problem — showing "OpenRouter API key missing" when the real issue
was gateway auth.

Changes:
- gateway/platforms/api_server.py: return "gateway_auth_failed" code
  with descriptive message for API_SERVER_KEY auth failures
- apps/desktop/src/store/notifications.ts: add "gateway_auth_failed"
  handler before "invalid_api_key" to show correct error message
- agent/error_classifier.py: add "gateway_auth_failed" to auth patterns
- tests: update test_session_api.py to expect new error code

Fixes #39365
2026-07-23 11:54:47 -07:00