Commit graph

2944 commits

Author SHA1 Message Date
Juan Martitegui
0bf471dd68 fix(gateway): preserve voice_only semantics for text input 2026-07-30 00:11:33 -07:00
Jeff Watts
53f7d137ed fix(windows): native Windows correctness for CLI, gateway status, banner, and WSL browser paths
Salvaged from #57016 by @lEWFkRAD:
- cli.py: handle file:///C:/... drive-letter URIs on nt (strip the
  leading slash urlparse leaves); join Termux example paths with literal
  forward slashes so hints stay POSIX on Windows.
- gateway/status.py + hermes_cli/gateway.py: normalize backslashes to
  forward slashes before the HERMES_HOME substring match so separator
  style cannot defeat profile ownership detection.
- hermes_cli/banner.py: cprint degrades to plain print when
  prompt_toolkit has no console (NoConsoleScreenBufferError on
  redirected/absent Windows stdout).
- hermes_cli/browser_connect.py: posixpath.join for WSL /mnt/c/... bases
  (os.path.join would emit backslashes on nt).
- Test hardening: symlink skip-guards, USERPROFILE alongside HOME for
  ntpath.expanduser, SIGKILL absence skipif fixed via monkeypatch,
  drive-letter URI / separator-normalization / banner-fallback coverage.

Dropped from the original PR: tests/cli/conftest.py fixture and the
AppSession _output monkeypatch — main's merged tests/cli/conftest.py
already handles that prompt_toolkit pollution.
2026-07-29 23:16:18 -07:00
Teknium
ad12df6ba4 Revert "remove Vercel AI Gateway and Vercel Sandbox (#33067)"
This reverts commit febc4cfec0.
2026-07-29 19:48:37 -07:00
Alexander Russell
cea4c3362d fix(gateway): collect quoted/spaced/home-relative MEDIA paths into the history dedup set
Salvaged from PR #73982 by Alexander Russell (@AlexxRussell) — the
collector half only. _collect_history_media_paths used only
_TOOL_MEDIA_RE, which misses quoted and spaced paths that the delivery
pipeline's extract_media grammar accepts; run text content through the
same extractor so the surviving dedup consumers (auto-append lane and
bare-path filter) see every path that could actually have been
delivered.

The PR's other halves (post-stream dedup snapshot plumbing, canonical
path comparison in _deliver_media_from_response, queued-followup
snapshot union) are moot after #74495 removed the post-stream history
filter entirely.
2026-07-29 19:11:05 -07:00
Kyzcreig
bcec6c8d39 fix(test): hermetic env-detection tests — pin container/supervisor/HOME probes (#422)
The restart-routing, systemd-support, and subprocess-HOME tests asserted
branch behavior but left part of the real probe surface unmocked, so they
fail when the suite itself runs inside a container (self-hosted CI) or a
launchd-descended shell:

- /restart routing tests: the handler also consults the real /.dockerenv —
  extract the inline probe to gateway.restart.is_container_restart_context()
  (patchable seam, no behavior change) and pin it False; scrub ALL four
  supervisor env markers (ambient XPC_SERVICE_NAME on macOS flipped one).
- supports_systemd_services tests: pin shutil.which('systemctl') and
  is_container() so the test asserts the branch, not the host.
- copilot ACP real-HOME test: pin is_container() (auto mode prefers profile
  home in containers) and scrub ambient HERMES_REAL_HOME/TERMINAL_HOME_MODE.

97 tests green on macOS dev box AND inside a docker CI runner container.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-07-29 18:55:10 -07:00
Teknium
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
2026-07-29 18:33:38 -07:00
webtecnica
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).
2026-07-29 18:33:38 -07:00
Ben Barclay
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.
2026-07-29 18:00:18 -07:00
brooklyn!
08e428ac8b
Merge pull request #74446 from NousResearch/bb/desktop-pairing
feat(pairing): profile-correct approvals, and a desktop surface to do them from
2026-07-29 19:04:36 -05:00
teknium1
4b33e5663b refactor: config auto-migration support floor at v12 + deprecated shim retirement 2026-07-29 16:44:31 -07:00
wgu9
a6397c379b fix(gateway): align multiplex pairing stores
Co-authored-by: x7peeps <x7peeps@users.noreply.github.com>
2026-07-29 18:43:50 -05:00
briandevans
4cbd46545e fix(gateway): scope pairing platform discovery to the profile dir
The per-profile pairing isolation added self._dir and scoped every
per-file path helper (_pending_path, _approved_path) to it, but
_all_platforms still enumerated the module-global PAIRING_DIR. For a
profile-scoped PairingStore, list_approved/list_pending/clear_pending
therefore operated on the GLOBAL platform set while loading each
platform's file from the PROFILE dir — so list_approved() returned []
for a user that is_approved() confirmed as approved, a silent divergence
between the authz surface and the list/inspect/clear surface.

Route discovery through self._dir. Byte-identical for the global store
(self._dir == PAIRING_DIR when no profile is set); only the buggy
profile-scoped case changes. self._dir is guaranteed to exist (__init__
mkdirs it).
2026-07-29 18:43:49 -05:00
teknium1
1a3a9de630 refactor(gateway): extract run_sync onto TurnRunner (completes the TurnContext seam; AST-identical body) 2026-07-29 16:21:46 -07:00
Brooklyn Nicholson
37d0766ba5 fix(pairing): keep GUI approvals off the code brute-force lockout
Follow-up hardening on the request-id grant path.

approve_request took the same lockout treatment as approve_code: gated by
it, and recording a miss toward it. But the two paths defend different
things. The lockout exists to stop guessing at the 8-char code space over a
messaging channel; a request id is only ever obtained by an admin already
authenticated to the store, so a miss means the row they clicked went stale.
Counting those let a handful of clicks on a stale list lock the operator out
of `hermes pairing approve` for an hour — the GUI DoSing the CLI.

Also drops the `code`/`code_hash_prefix` compat fields from list_pending.
The hash prefix is what admin surfaces mistook for an approvable code in the
first place, and re-exporting the request id under the old `code` key just
preserves the ambiguity; both consumers in the tree read `request_id` now.
The 16-hex sniffing that had been copy-pasted into the CLI and the endpoint
(where a chained conditional consulted it against the wrong field) moves to
one owner, PairingStore.looks_like_request_id.

The endpoint no longer reports a 429 on the request-id path, where lockout
can't apply — a stale id surfaced as a bogus "locked out" while the platform
sat locked for something else entirely.
2026-07-29 17:51:40 -05:00
solyanviktor-star
c352a322b0 fix(pairing): reset the failed-approval counter on a successful approval
approve_code()'s success path never cleared _failures:{platform}. The
counter is incremented on every non-matching code, persisted in
_rate_limits.json, and only ever reset to 0 when it reaches
MAX_FAILED_ATTEMPTS (firing the lockout). So it counts failures over the
gateway's entire lifetime, not consecutive ones.

An owner who mistypes a pairing code on a handful of separate occasions
— each time immediately retyping it correctly and successfully pairing —
accumulates those isolated typos. A later single fresh typo then hits
MAX_FAILED_ATTEMPTS and locks the whole platform out for an hour, at
which point _is_locked_out gates approve_code and even the *correct*
code is rejected.

Reset the counter on a successful approval, matching standard
brute-force-guard semantics (the counter tracks consecutive failures).
This does not weaken protection: an attacker cannot produce a success
without a valid code, and 5 consecutive wrong attempts still lock out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:49:33 -05:00
Flownium
774d92fbfa fix: approve listed pairing requests 2026-07-29 17:49:33 -05:00
Teknium
1a088989bc
Merge pull request #66730 from NousResearch/feat/hsp-sync-client
feat(sync): HSP/1 personal skill sync client (M1 client)
2026-07-29 15:20:35 -07:00
Teknium
fc551f9691
Merge pull request #72103 from victor-kyriazakos/feat/relay-slack-blockkit-native-parity
fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity)
2026-07-29 15:13:42 -07:00
teknium1
a022229566 refactor(gateway): TurnContext/TurnRunner seam — extract _run_agent_inner nested closures (byte-identical bodies) 2026-07-29 13:34:03 -07:00
Ben Barclay
f5b68ad58b Merge origin/main into feat/hsp-sync-client
Resolves the PR's conflict with main (2252 commits). Two conflicts, both
"each side added an independent block in the same place" — kept both:

- gateway/run.py — the housekeeping loop. This branch adds the Skill Sync
  pulls inside the CURATOR_EVERY branch (12-space indent); main adds a
  stale-session auto-archive as a sibling `if` at loop level (8-space).
  Different scopes, so the naive union would have mis-nested the archive
  block into the curator branch; kept each at its own indent level.
- tools/skill_manager_tool.py — the _edit_skill result dict. This branch
  appends the org auto-propose note; main appends
  _add_description_prompt_preview(). Independent, order-insensitive.

No behaviour dropped from either side.

Verified: 3552 passed / 0 failed across 63 suites (scope regenerated to
include main's new maybe_auto_archive / _add_description_prompt_preview
consumers) via scripts/run_tests.sh. `hermes sync` and `hermes sync status`
still work against a live token, resolving the production plane default.

The Pyright Optional-parameter warnings in skill_manager_tool.py are
pre-existing on main (`content: str = None` etc.), not introduced here.
2026-07-29 13:01:36 -07:00
teknium1
bcb352eeab refactor: registry-owned execute() on CommandDef — informational commands unified (thin slice) 2026-07-29 12:11:24 -07:00
teknium1
ab08e8fc76 refactor(gateway): consolidate 19 session-keyed dicts into SessionState (turn/conversation/persistent scopes; eliminates wholesale-reset races)
GatewayRunner carried ~19 separate Dict[str, ...] attributes keyed by
session_key, each with an ad-hoc lifecycle. They now live in one
`self._sessions: dict[str, SessionState]` (gateway/session_state.py) with
three lifecycle scopes and a `_session_state(key)` get-or-create accessor.
Mechanical refactor: same state, same semantics, new container.

Migration table (dict -> old decl line -> current clear path -> new home):

| legacy dict                              | decl  | cleared by (before)                              | SessionState field                     |
|------------------------------------------|-------|--------------------------------------------------|----------------------------------------|
| _running_agents                          | 3505  | _release_running_agent_state; stop() .clear()    | turn.agent                             |
| _running_agents_ts                       | 3506  | same                                             | turn.started_ts                        |
| _active_session_leases                   | 3507  | same (+ lease.release())                         | turn.lease                             |
| _busy_ack_ts                             | 3539  | same                                             | turn.busy_ack_ts                       |
| _turn_lease_tokens ((key, gen)-keyed)    | 3518  | _release_turn_lease (generation-guarded)         | turn.lease_token + turn.lease_generation |
| _session_model_overrides                 | 3585  | _CONVERSATION_SCOPED_STATE funnel                | conversation.model_override            |
| _pending_one_turn_model_restores         | 3586  | funnel; one-shot pop in turn finally             | conversation.one_turn_restore          |
| _session_reasoning_overrides             | 3589  | funnel; lazy-init dict swap ~5692 (RACE)         | conversation.reasoning_override        |
| _session_service_tier_overrides          | 3592  | funnel; lazy-init dict swap ~5738 (RACE)         | conversation.service_tier_override     |
| _last_resolved_model ("*" = process-wide)| 3527  | funnel                                           | conversation.last_resolved_model       |
| _queued_events                           | 3537  | funnel; lazy-init dict swap ~5204 (RACE)         | conversation.queued_events             |
| _pending_turn_sidecar_notes              | 3597  | funnel; lazy-init dict swap ~19832 (RACE)        | conversation.sidecar_notes             |
| _session_ephemeral_pin                   | 3601  | agent-cache evict pop; lazy swap ~19885 (RACE)   | conversation.ephemeral_pin             |
| _session_vc_last                         | 3604  | agent-cache evict pop; lazy swap ~19864 (RACE)   | conversation.vc_last                   |
| _pending_approvals                       | 3611  | boundary security funnel; stop() .clear()        | persistent.approvals                   |
| _update_prompt_pending                   | 3623  | security funnel; update watcher pops             | persistent.update_prompt_pending       |
| _pending_native_image_paths_by_session   | 3538  | one-shot consume; lazy swap ~12944 (RACE)        | persistent.native_image_paths          |
| _pending_messages (runner-level, str)    | 3519  | _interrupt_and_clear_session pop; stop() flush   | persistent.pending_command_text        |
| _session_run_generation                  | 3540  | NEVER (monotonic, #28686)                        | persistent.run_generation (never reset)|

Races eliminated: every `self._X = {}` lazy-init/reset replaced the WHOLE
dict, so a writer on session A racing a lazy init triggered by session B
could lose its entry. All six such sites (_session_reasoning_overrides
~5692, _session_service_tier_overrides ~5738, _queued_events ~5204,
_pending_turn_sidecar_notes ~19832, _session_ephemeral_pin ~19885,
_session_vc_last ~19864, _pending_native_image_paths_by_session ~12944,
_turn_lease_tokens ~13644) are now per-session field writes on an existing
SessionState; a reset can no longer cross sessions structurally.

Registry successors:
- _release_running_agent_state -> state.turn.clear() (one structured reset
  instead of the drifting pop-list; still pops the slot lease and calls
  lease.release() first; still generation-guarded).
- _CONVERSATION_SCOPED_STATE funnel -> state.conversation.clear(); the
  tuple is retained for legacy plain-dict stores not yet folded in
  (_pending_model_notes) and for the public test contract.
- _turn_lease_tokens' (key, generation) tuple key -> lease_token +
  lease_generation fields; release/rebind only match when the generation is
  current, preserving the #28686/#64934 ownership check.
- _session_run_generation stays monotonic on persistent.run_generation and
  is never cleared (conversation boundaries and turn releases don't touch it).

Compatibility adapters: tests (and a few mixin call sites) access the old
dict names directly (137 direct assignments to _running_agents alone), so
each legacy name is kept as a thin @property returning a live MutableMapping
view over the corresponding SessionState field (legacy_dict_property /
legacy_lease_token_property in session_state.py). Setter accepts a plain
dict (the `runner._X = {...}` test pattern); views support ==, in, len,
.get/.pop/.clear. The shutdown path in _stop_impl deliberately keeps
duck-typed legacy-attribute access because test fakes borrow it with plain
dicts.

Name collision noted (NOT touched, out of scope): gateway/platforms/base.py
has its own _pending_messages Dict[str, MessageEvent] (adapter-level slot);
the runner-level Dict[str, str] of the same name is what moved to
persistent.pending_command_text.

Entry leaks preserved (follow-up, no new eviction in this PR): SessionState
entries in self._sessions are never evicted, matching the old dicts — e.g.
_last_resolved_model, _session_run_generation, _session_vc_last entries for
dead sessions leaked before and their fields still occupy a SessionState now.

Verification: 123 tests/gateway files referencing the old names +
_release_running_agent_state + _CONVERSATION_SCOPED_STATE all pass (sole
failure test_feishu.py::test_websocket_sdk_accepts_channel_ua_tag is
pre-existing, stash-verified); 8 non-gateway test files touching the names
pass (266 tests); `import gateway.run` subprocess smoke OK; ruff clean;
post-migration grep shows zero non-comment `self._<oldname>` references in
run.py outside the property adapters and the duck-typed shutdown block.
2026-07-29 12:11:15 -07:00
Ben Barclay
0dc293f4f7 fix(relay): coerce Slack behavior flags exactly as the native adapter does
Both relay Slack knobs read their value through bool(), while the native
adapter they mirror uses str(raw).strip().lower() in {"1","true","yes","on"}.
A YAML-quoted string diverges:

    dm_top_level_threads_as_sessions: "false"   → relay True, native False

Non-empty strings are truthy, so the escape hatch is silently ignored in
exactly the shape an operator writes to switch it OFF. reply_in_thread has the
same defect and gates reply placement, session keying and run.py's progress
resolver, so one quoted "false" misfires three ways.

Route both through a shared _coerce_flag mirroring native's predicate. Real
booleans pass through untouched; None falls back to the default. Contract §8
documents the accepted spellings.

Tests: both knobs parametrized over the true/false spellings native accepts,
plus the absent-key default.
2026-07-29 12:03:01 -07:00
Ben Barclay
33833e232e fix(relay): apply the Slack thread anchor on the media lane too
The DM thread-anchor contract was resolved only in send(). _send_media() —
backing send_image, send_image_file, send_voice, send_video and send_document
— passed reply_to straight to the frame and never touched metadata, so
attachments egressing through the same connector-side Slack sender got both
failure shapes this branch set out to remove:

  flat mode   → reply_to survives, the image threads UNDER the user's DM
                message (the original reported symptom)
  thread mode → no metadata.thread_id, and threadTs() never reads reply_to,
                so the image lands in the home channel instead of the
                per-message thread

Both are reachable: gateway/run.py delivers agent artifacts through
send_voice/send_document.

Extract the three steps that must always happen together (mode gate, mirrored
reply_to_message_id strip, metadata promotion) into
_apply_slack_thread_anchor and route BOTH lanes through it, so text and media
cannot drift again. The media lane copies caller metadata rather than mutating
it — these helpers are called in loops with a shared mapping.

Also fold send_typing/stop_typing's duplicated status-anchor blocks into
_with_status_thread_anchor. They had already drifted (stop_typing omitted the
platform check) and the clear must target the thread the heartbeat set or the
status line sticks until Slack's own timeout.

Tests: media lane pinned in both modes plus the channel and
no-caller-mutation cases; verified as real by reverting the fix and watching
them fail.
2026-07-29 12:03:00 -07:00
teknium1
ba7da1332c refactor: single-owner model switch parsing + effective-model resolution (kills the api_server/run.py divergence class) 2026-07-29 11:54:09 -07:00
teknium1
bd93ccb890 refactor(gateway): shared fence-aware markdown chunker core (yuanbao-derived) + canonical table-row splitter 2026-07-29 11:53:59 -07:00
teknium1
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).
2026-07-29 11:53:39 -07:00
teknium1
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).
2026-07-29 11:33:41 -07:00
teknium1
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.
2026-07-29 11:19:16 -07:00
teknium1
4fe5410d57 refactor(gateway): shared reaction-ack policy in base adapter
- base.on_processing_complete implements the opt-in remove-ack/add-outcome
  flow driven by _OK_EMOJI/_FAIL_EMOJI class attrs and the
  _add_reaction(chat_id, message_id, emoji)/_remove_reaction(chat_id,
  message_id) primitive shape; default stays a no-op.
- photon drops its override (exact behavioral match).
- slack/discord/feishu/matrix/telegram/google_chat keep overrides: divergent
  primitive signatures (team_id routing, raw message objects, reaction-id
  handles, replace-semantics setMessageReaction) or extra state protocols.
2026-07-29 11:19:16 -07:00
teknium1
58400a6793 refactor(gateway): promote compile_mention_patterns to helpers 2026-07-29 11:19:16 -07:00
teknium1
2006cd5895 refactor(gateway): declarative busy_policy on CommandDef replaces hand-written mid-run command chain 2026-07-29 10:53:56 -07:00
teknium1
ed33ebca1d refactor: canonical config loaders for behavioral reads + guarded raw-read primitive (kills the managed-scope/env-expansion drift class)
The disease: ~15 scattered raw yaml.safe_load(config.yaml) reads that
silently miss managed-scope overlay, ${ENV_VAR} expansion, profile-aware
pathing, and root-model normalization. Every new config feature needed an
N-site sweep (incident chain 9cbcc0c9c8732293cf87b0e47a98f91928aa0443). This commit assigns every raw read to an owner and adds a
lint-guard test so the class cannot regrow.

New primitive (additive-only change to hermes_cli/config.py):
  read_user_config_raw(path=None) — reads the user file EXACTLY as
  written; docstring states it is ONLY legal for write-back round-trips
  and raw-file diagnostics. Behavioral reads must use
  load_config()/load_config_readonly().

BEHAVIOR FIXES (class-a sites migrated to a canonical loader — these
previously read values that could DIFFER from the effective config):

  gateway/run.py _try_resolve_fallback_provider → _load_gateway_runtime_config
    keys: fallback_providers/fallback_model (provider, model, base_url,
    api_key). Drift fixed: a managed-pinned fallback chain was ignored;
    an api_key of "${OPENROUTER_API_KEY}" reached the resolver unexpanded.
  gateway/run.py GatewayRunner._load_provider_routing → same loader
    key: provider_routing. Drift fixed: managed-pinned routing prefs and
    ${VAR} templates were ignored.
  gateway/run.py GatewayRunner._load_fallback_model → same loader
    keys: fallback chain. Same drift as above.
  gateway/run.py GatewayRunner._refresh_fallback_model
    keeps the raw primitive (its last-known-good-on-parse-failure contract
    forbids the fail-open loader, which returns {} on a torn write) but now
    applies managed overlay + env expansion inline. Drift fixed: chain
    edits under managed scope / env templates were previously frozen out.
  tui_gateway/server.py _load_cfg (72 behavioral call sites)
    now = raw read + managed overlay (pre-existing) + NEW ${VAR} expansion,
    split from a new _load_cfg_raw() write-back primitive. Drift fixed:
    e.g. custom_prompt: "hello ${VAR}", agent.system_prompt, model,
    api_key/base_url templates reached sessions unexpanded. DEFAULT_CONFIG
    is deliberately NOT merged (callers treat missing keys as unset;
    `_load_cfg() == {}` sentinels and _save_cfg round-trips depend on it).
  tui_gateway/server.py _profile_configured_cwd
    keys: terminal.cwd of a NON-launch profile. Drift fixed: managed
    overlay + ${VAR} expansion now apply (load_config() would resolve the
    wrong profile's home, so the raw primitive + inline pipeline is used).
  plugins/platforms/telegram/adapter.py _reload_dm_topics_from_config
    → load_config_readonly(). keys: platforms.telegram.extra.dm_topics.
    Drift fixed: managed overlay + profile-aware pathing + expansion.
  plugins/memory/holographic _load_plugin_config → load_config_readonly().
    keys: plugins.hermes-memory-store.*. Same drift class.

WRITE-BACK ROUND-TRIPS (class-b: stay raw BY DESIGN via read_user_config_raw;
merging defaults/overlay would pollute the saved user file):
  gateway/slash_commands.py: model persist x2, _save_gateway_config_key,
    memory/skills write_approval toggles
  gateway/platforms/yuanbao.py auto-sethome
  tui_gateway/server.py _write_config_key + all cfg→_save_cfg blocks
    (reasoning show/hide/full/clamp, details_mode[.section], prompt)
    → new _load_cfg_raw()
  plugins/memory/holographic save_config

RAW-FILE DIAGNOSTICS + presence-sensitive bridges (class-c: stay raw,
now via the shared primitive with an explanatory comment):
  hermes_cli/doctor.py x5 (model validation, stale-root-keys, .env drift,
    deprecation sweep, memory-provider probe — the latter two keep their
    inline managed overlay where they had one)
  gateway/run.py _bridge_max_turns_from_config and the module-level
    TERMINAL_*/HERMES_* env bridge (bridging merged defaults would export
    all of DEFAULT_CONFIG into the environment; both keep their inline
    overlay + expansion)
  hermes_cli/send_cmd.py env bridge (same presence-sensitivity)
  hermes_cli/gateway.py multiplex-conflict probe (reads the DEFAULT root's
    config, not the active profile's — load_config is the wrong owner)
  hermes_cli/profiles.py / hermes_cli/web_server.py / tools/wake_word.py
    multi-profile reads (load_config targets only the ACTIVE profile home)
  cron/jobs.py _resolve_default_model_snapshot and cron/scheduler.py
    run_job config read keep their existing inline overlay+expansion but
    now share the primitive (their fail-open + last-value semantics and
    the deliberate no-defaults merge are preserved exactly).

Failure-semantics audit: every migrated site preserves its exact previous
behavior on missing file ({} / early return) and parse failure (raise into
the caller's existing except, warn, last-known-good, or fail-open) —
read_user_config_raw intentionally mirrors bare open()+safe_load semantics
(raises on parse errors, {} only on FileNotFoundError/non-dict root).

Guard: tests/hermes_cli/test_config_read_guard.py scans the tree for
yaml.safe_load within 6 lines of a 'config.yaml' reference outside an
explicit ALLOWLIST (hermes_cli/config.py, gateway/config.py, gateway/run.py
fallback path, hermes_cli/managed_scope.py which reads the MANAGED file,
gateway/readiness.py parse-health probe) and fails on new offenders.

E2E: tests/hermes_cli/test_config_loader_e2e.py runs a subprocess with a
temp HERMES_HOME (config.yaml containing ${E2E_PROMPT_SUFFIX}) plus a
HERMES_MANAGED_DIR overlay pinning agent.reasoning_effort, asserting
tui _load_cfg resolves "hello world"/"high" while _load_cfg_raw +
_save_cfg round-trip the template and user value verbatim with no
managed/default leakage.
2026-07-29 10:53:29 -07:00
teknium1
bf15259e33 refactor(gateway): shared media-cache mime dispatch for adapter downloads (per-adapter overrides preserve historical mappings) 2026-07-29 10:14:59 -07:00
teknium1
7b6a67f82e refactor(gateway): extract duplicated StreamConsumerConfig setup into _build_stream_consumer_config (preserves both sites' divergent fallback semantics via parameter) 2026-07-29 10:14:45 -07:00
teknium1
3d48f893da refactor: single build_subprocess_env() factory for all child-process spawns (profile + secret-scrub single owner) 2026-07-29 10:14:11 -07:00
teknium1
6f7f7cd064 refactor: use shared strip_ansi for inline ANSI regexes 2026-07-29 10:13:50 -07:00
teknium1
7c198c5e44 refactor: single shared Retry-After parser 2026-07-29 10:13:50 -07:00
teknium1
19055492aa fix: route stray HERMES_HOME hardcodes through get_hermes_home() (profile + native-Windows safety) 2026-07-29 09:33:48 -07:00
victor-kyriazakos
1773752c8c Merge remote-tracking branch 'origin/main' into feat/gateway-health-diagnostics-monitoring
# Conflicts:
#	uv.lock
2026-07-29 15:37:14 +00:00
Enough1122
5cc5c58e01 fix(gateway): flush pending memory writes before session teardown (#73297)
The gateway's /reset cleanup path called shutdown_memory_provider without
first draining the memory manager's serialized background write worker.
shutdown_all only gives that worker a bounded (~5s) drain and abandons
whatever is still queued past it, so a /reset could silently drop writes
the session had already handed off -- the next session then loaded
stale MEMORY.md.

The CLI exit path already drains via MemoryManager.flush_pending before
shutdown; this PR pins the same contract on the gateway cleanup path.

Cleanup now calls agent._memory_manager.flush_pending(timeout=10) before
the existing shutdown_memory_provider step. The flush is best-effort:
a flush failure must never block teardown, so it is wrapped in
try/except and the existing shutdown path remains the fallback.

Closes #73297
2026-07-29 17:21:55 +05:30
kshitijk4poor
222ea2b6c9 refactor: fold simplify-code review findings
- extract _commit_registry/_note_refresh_failure shared by the background
  worker and foreground stage-4 (identical 4-step success + failure paths
  were duplicated); worker now commits under _models_dev_fetch_lock so a
  failing background refresh can never re-arm the backoff immediately
  after a successful force_refresh committed (unsynchronized-write race)
- add should_clear_context_pin_async to hermes_cli/route_identity.py
  (matching the get_model_context_length_async precedent) and use it at
  the 4 async gateway sites instead of inline asyncio.to_thread wraps;
  the sync _format_session_info site keeps the sync call (already
  off-loop via its callers' to_thread)
- test the background-refresh success path (the PR's primary new
  behavior): disk saved, mem cache swapped, backoff cleared, in_flight
  reset — mutation-checked
- replace the race-prone spin-wait on _models_dev_refresh_in_flight with
  a named-thread join in the backoff test
2026-07-29 17:13:48 +05:30
StellarisW
8c50aaceb6 fix(gateway): keep models.dev refreshes off event loop 2026-07-29 17:13:48 +05:30
kshitijk4poor
23e44a2843 fix: consolidate agent-history preservation into shutdown_flush module
Follow-up for salvaged PRs #73400 + #73372:
- Move _preserve_agent_history_on_shutdown logic into
  gateway/shutdown_flush.py as flush_agent_history_to_file()
- Reuse the hardened _write_payload (atomic writes, fsync, private
  permissions, UUID filenames) from #73372 instead of the plain
  open()/write() from #73400
- Replace os.environ.get('HERMES_HOME') with get_hermes_home() for
  profile-safe path resolution
- Update tests to test the real function directly
- Net: removes 37 lines from gateway/run.py, unifies both fix paths
  in a single module with consistent atomic-write guarantees
2026-07-29 16:45:35 +05:30
Baophan00
40837e2dd0 Fix #72680 (retargeted): preserve agent._session_messages on shutdown flush failure
The previous attempt (#73171) snapshotted GatewayRunner._pending_messages,
which on current main has no writers (commit f6736ced8 removed its write
path; interrupt delivery uses adapter._pending_messages instead). The live
container is the per-agent agent._session_messages, flushed via
_flush_messages_to_session_db. When that flush raises (FTS/SQLite corruption,
the disk=0/memory=N state from #72680), the in-memory transcript is lost when
the process exits.

Retarget the preservation to the real path: in _finalize_shutdown_agents, wrap
the _flush call; on exception, dump agent._session_messages to an external JSON
recovery snapshot under $HERMES_HOME/shutdown-recovery/ (tagged issue=#72680)
so an operator can salvage it after repairing state.db. The dump is fully
guarded (non-fatal) so shutdown never blocks on a best-effort backup.

This directly addresses the reviewer note on #73171: retarget to the actual
cached-agent history (agent._session_messages) and prove a stale DB + shutdown
leaves a recoverable transcript.

Regression tests: tests/gateway/test_session_messages_shutdown_preserve.py
- flush raises -> recovery file written with session_id + messages
- healthy flush -> no recovery file
- write error -> non-fatal, no raise

Fixes #72680
2026-07-29 16:45:35 +05:30
kshitij
720cdd1d14 refactor: use atomic_json_write instead of hand-rolled _write_payload
Replace 20 lines of manual os.open/O_EXCL/fdopen/fsync/os.replace with the
existing atomic_json_write() from utils.py, which is already used by 6+
modules and handles temp-file creation, fsync, atomic replace, mode
control, and owner preservation. The only novel helper (_fsync_directory)
is retained — atomic_json_write does not do directory fsync.

Update test_flush_write_failure_leaves_no_recovery_file to monkeypatch
utils.os.replace (the new call path) instead of gateway.shutdown_flush.os.replace.
2026-07-29 11:16:09 +05:30
deacon-botdoctor
72024950cf fix(gateway): harden shutdown message flush 2026-07-29 11:16:09 +05:30
Teknium
2c771be406 fix(gateway): dual-stack webhook bind for wecom/msgraph/whatsapp_cloud/teams/telegram siblings
Same class of bug as the LINE adapter (NS-603): defaulting the webhook
bind to "0.0.0.0" (or hardcoding it) binds IPv4 ONLY, so the listener
is unreachable over IPv6-only private networks such as Fly.io 6PN.

- wecom callback_adapter: DEFAULT_HOST None; config.py env seed no
  longer forces 0.0.0.0 when WECOM_CALLBACK_HOST is unset.
- msgraph_webhook: DEFAULT_HOST None; the allowed_source_cidrs
  requirement still fires for the all-interfaces default (host=None is
  treated as network-accessible).
- whatsapp_cloud: DEFAULT_WEBHOOK_HOST None.
- teams: hardcoded 0.0.0.0 TCPSite bind → _DEFAULT_HOST=None with new
  TEAMS_HOST / extra.host override (mirrors LINE_HOST pattern).
- telegram: hardcoded listen="0.0.0.0" → default "" (tornado
  bind_sockets opens one socket per address family; verified against
  PTB 22.6/tornado) with new TELEGRAM_WEBHOOK_HOST / extra.webhook_host
  override.

Explicit host overrides everywhere are preserved; empty/unset collapses
to the dual-stack default. "::" remains a bad substitute on
bindv6only=1 hosts (see LINE adapter comment).
2026-07-28 22:42:41 -07:00
Teknium
a65494ed00 fix(gateway): treat pid=None lifecycle sentinel as unknown ownership in mark_exited
The ownership guard let a sentinel with pid=None pass as self-owned, so
an exiting life could clobber evidence of unknown provenance with a
clean-exit claim. Tighten: only rewrite when the sentinel pid matches
os.getpid() exactly; pid=None (or malformed) is left untouched. Adds
tests for the pid=None no-op and the own-pid rewrite paths.
2026-07-28 22:41:09 -07:00
Shannon Sands
6459b8df76 fix(gateway): use no-kill _pid_exists probe in lifecycle ledger
scripts/check-windows-footguns.py (blocking CI lint) rightly flagged the
os.kill(pid, 0) liveness probe: on Windows sig=0 collides with
CTRL_C_EVENT and GenerateConsoleCtrlEvent hard-kills the target's whole
console group (bpo-14484) — a forensics module must never be able to
kill the process it's checking on. Route through gateway.status._pid_exists,
the repo's canonical psutil-backed no-kill probe.
2026-07-28 22:41:09 -07:00