Commit graph

7871 commits

Author SHA1 Message Date
Shannon Sands
d1be769b45 feat(dashboard): clarify manual Telegram bot setup 2026-07-16 00:21:01 -07:00
Shannon Sands
3ffd8b3da0 fix(dashboard): persist Discord toolsets to Discord platform 2026-07-16 00:20:33 -07:00
ethernet
c80b244b52 refactor(terminal,file-tools): delete legacy env-side cwd tracking (step 4)
The per-session record store is now the ONLY cwd mechanism. Deleted:

- env.cwd_owner stamping + prev_owner threading (terminal_tool): the
  shared env no longer carries ownership metadata at all
- _resolve_command_cwd's env/prev_owner params: resolution is
  workdir > session record > config/override default
- file_tools._live_cwd_if_owned + _get_live_tracking_cwd: path
  resolution never consults the shared env's live cwd
- file_tools._last_known_cwd + _remember_last_known_cwd +
  _last_known_cwd_for: the #26211 preserved-anchor registry is
  subsumed by the session record, which never lived on the env and
  therefore cannot be lost to env cleanup. The _get_file_ops
  stale-cache rescue now writes the record instead.
- env recreation (both _get_file_ops and terminal_tool) seeds the
  fresh env from override > session record > config

Why no transition fallback: the legacy state was process-local and
in-memory exactly like the record store — after a restart both start
empty, and within a running process every legacy write site has been
dual-writing the record since step 1. There is no populated-legacy/
empty-record state to fall back for.

Tests updated to drive the record store instead of the deleted
mechanism; the cross-session isolation suite now asserts the same
behavior contracts (no leak, cd isolation, #26211 persistence)
against the new architecture, plus a new "session C inherits nothing"
case that the old ownership guard could not express.
2026-07-16 00:18:38 -07:00
ethernet
fd3d9c63d0 refactor(terminal): resolve command cwd from per-session records (step 3)
Third step of the cwd rearchitecture: _resolve_command_cwd now prefers
the session's own cwd record over the shared env's live cwd.

New resolution order: workdir > session record > legacy env.cwd
(ownership-gated, transition-only) > config/override default.

The record is written after every completed command for the session, so
it IS the session's cd state — another session's cd lands in another
record and cannot affect this session's commands. The legacy env.cwd
branch only fires for a session with no record yet (no command has
completed since this code loaded); it keeps the prev_owner ownership
guard for that transition window and is deleted in step 4 along with
env.cwd_owner stamping and file_tools' _last_known_cwd machinery.

Adds command-path regression tests including the terminal sibling of
the leak-A scenario (unowned shared env cwd vs session record) and an
E2E cd round-trip through terminal_tool.
2026-07-16 00:18:38 -07:00
ethernet
5461e0e098 refactor(file-tools): resolve paths against per-session cwd records (step 2)
Flips the read side of the cwd rearchitecture onto the _session_cwd
store introduced in the previous commit.

_authoritative_workspace_root now resolves:
  1. the session's own cwd record (get_session_cwd) — per-session by
     construction, so one session's cd can never leak into another
     session's file resolution, with no ownership heuristics at all
  2. registered override (fallback for cleared/never-written records)
  3. legacy shared-env live cwd + preserved anchor (transition-only,
     for commands that ran before this code loaded)
  4. sentinel-free absolute TERMINAL_CWD

delegate_task children get their record seeded from the parent's at
spawn: they keep starting in the parent's directory (current behavior)
but their subsequent cds stay isolated in their own record instead of
bleeding back through the shared env.

The wrong-worktree leak class is now solved structurally on this path —
there is no shared cwd for sessions to inherit. The legacy env-side
tracking (cwd_owner, _live_cwd_if_owned, _last_known_cwd) remains only
as a transition fallback and is deleted in the next step.
2026-07-16 00:18:38 -07:00
ethernet
be2a1290de refactor(terminal): introduce per-session cwd records (step 1: dual-write)
First step of the cwd rearchitecture (see PR #65185 for the targeted
leak fixes this will eventually supersede, and
.hermes/plans/cwd-rearch-audit.md for the full audit + sequencing).

The root cause of the wrong-worktree bug class is that cwd lives on the
SHARED terminal env — a global mutable timeshared between sessions.
env.cwd_owner stamping, _last_known_cwd, and file_tools' ownership
ladder are all patches over that misplacement.

This adds the replacement store: _session_cwd, keyed by the raw
session/task key, with record/get/clear accessors. Step 1 is dual-write
only — every site that learns a session's live cwd also records it:

- terminal_tool foreground path: after env.execute() the env's own
  post-command tracking has updated env.cwd; mirror it under the
  session key that drove the command
- register_task_env_overrides: a registered workspace cwd (ACP/TUI/
  desktop) seeds the session record
- clear_task_env_overrides: drops the record on teardown

Readers are untouched — behavior is identical. Later steps flip
file_tools resolution and _resolve_command_cwd to read this store,
then delete env-side tracking, cwd_owner, and _last_known_cwd.

Also hardens terminal_tool's env acquisition with an explicit
env-is-None guard (previously implicitly unbound on an unreachable
branch, flagged by pyright once the dual-write read env post-loop).
2026-07-16 00:18:38 -07:00
kshitijk4poor
92876effe2 fix(webhook): make dual-stack bind exclusive
Disable address reuse so an existing family-specific listener cannot silently split traffic with the webhook server. Normalize wildcard bind hosts for local CLI URLs and align setup documentation with the dual-stack default.
2026-07-16 12:36:51 +05:30
Ben
d542894adf fix(webhook): default to dual-stack bind so 6PN (IPv6) can reach the adapter
The webhook adapter defaulted to host='0.0.0.0' — IPv4 only. On Fly.io
hosted agents the edge router (hermes-agent-router) reverse-proxies public
webhook traffic to <app>.internal:8644 over 6PN, Fly's private network,
which is IPv6-only (.internal resolves to an fdaa:… address). An IPv4-only
listener is unreachable there, so public webhook POSTs to
https://<agent>.agents.nousresearch.com/webhooks/<route> never landed on
the adapter — the router's dial was refused.

Fix: DEFAULT_HOST = None, which makes aiohttp/asyncio create_server bind
BOTH address families. '::' is NOT a valid substitute: on hosts where the
kernel sets bindv6only=1 (verified on Fly machines) it yields an IPv6-only
socket, breaking the IPv4 loopback /health check and the AF_INET
port-conflict probe in connect(). None binds per-family regardless of the
sysctl. An explicit empty-string/null host in config now also normalises to
None (dual-stack) rather than an invalid host=''. Users can still pin a
specific host via platforms.webhook.extra.host.

Validated live on a Fly staging agent: with this default and no config
override, the adapter binds both v4 and v6 (127.0.0.1:8644 and [::1]:8644
both answer), and a public signed webhook POST through the router returns
202 (valid sig) / 401 (bad sig) instead of the router's 502.

Tests: new TestDualStackBind asserts the None default, config resolution
(missing/empty→None, pinned preserved), and a real dual-stack bind opens
both AF_INET and AF_INET6 listeners. Red-proof: these fail on the old
'0.0.0.0' default.
2026-07-16 12:36:51 +05:30
Yingliang Zhang
ca803523ff test(tui): cover live notification ownership routing
Adapt the strongest #63317 live-loop handoff regression and cover lineage lookup failure plus addressed live-loop orphans.

Co-authored-by: Abhinav Bansal <abhibansal-sg@users.noreply.github.com>
2026-07-15 23:51:43 -07:00
2751738943
54d0948d38 fix(tui): route post-turn completions by owner
Apply positive-proof routing to every addressed notification in the registry and TUI poller while preserving ownerless legacy behavior and TUI delivery for poll-observed completions.

Remove the unused exact-key drain helper and cover ordinary success and failure, origin, compression-lineage, orphan, and poll-observed paths.

Complements NousResearch/hermes-agent#54785.
2026-07-15 23:51:43 -07:00
Yingliang Zhang
81fc24862c fix(tui): route bg process notifications to owning session, drop orphaned events
Two complementary fixes for cross-session background-process notification
leakage in the TUI/Desktop multi-session path (#42674, #35652).

1. Poller orphan guard: after _notification_event_belongs_elsewhere
   returns False, check whether the event has a non-empty session_key
   that differs from the current session.  If so the owner session is
   gone — drop the event instead of hijacking it into an unrelated
   session transcript.

2. Post-turn drain filter: the existing drain_notifications() pops every
   event from the global queue regardless of ownership.  Added
   _drain_owned_notifications() which applies the same ownership routing
   used by the poller (consume own, requeue foreign-live, drop orphan),
   and wired it into the post-turn safety drain.

Complementary to PR #42731 which addresses a separate code path in the
same bug class.  Together they close #42674.
2026-07-15 23:51:43 -07:00
Ben Barclay
2ea39daeb1
fix(gateway): share relay adapter in multiplex mode (#65366) 2026-07-16 14:30:42 +10:00
Ben Barclay
6a35f9e667
fix(container): keep named multiplex gateway slots down (#65368) 2026-07-16 14:30:05 +10:00
ethernet
f8ddf4fd86
feat(ci): semantic package-lock.json diff as an upserted PR comment (#65206)
git diff on a lockfile is unreadable: npm reorders entries, rewrites
integrity hashes, and moves packages between nesting levels, so a
one-line package.json bump produces a thousand-line textual diff.

scripts/ci/lockfile_diff.py instead parses the `packages` map out of
both versions of every tracked package-lock.json (via `git show`),
reduces each to {install path: version}, and set-diffs the maps —
reorder/hash churn vanishes, leaving only actual version movement
(added / removed / updated, with nested dedup copies tracked
separately).

The lockfile-diff workflow posts the result as a Markdown table in a
PR comment gated behind a hidden marker: subsequent pushes PATCH the
existing comment instead of stacking new ones, and a push that reverts
all lockfile changes updates the comment to say so. Advisory only —
never fails on findings; fork PRs (read-only token) degrade to a
warning.

Wired through the ci.yml orchestrator with a new npm_lock lane in
classify_changes.py (fails open on .github/ changes per the existing
contract).
2026-07-16 03:18:15 +00:00
Ben Barclay
3f2a389c7e
fix(auth): apply newer hosted bootstrap session (#64612)
* fix(auth): apply newer hosted bootstrap session

* fix(auth): validate rebootstrap replacement seeds
2026-07-16 08:52:47 +10:00
ethernet
dbf86b9234 test: port macOS entitlements test from Python to vitest
tests/test_desktop_mac_entitlements.py asserts about
apps/desktop/electron/*.plist — the same CI blind spot as the other
ported tests: the change classifier routes apps/ changes to the
frontend lane, so a PR touching only the plists would skip the Python
suite and the regression would go green on the PR and red on main.

Ported to tests-js/desktop-mac-entitlements.test.ts so it runs in the
correct lane. All three tests carry over: the inherit plist grants
audio-input (regression #37718), every device.* entitlement on the
main app is also inherited, and both files remain well-formed.

Parsing uses the `plist` package (pinned to ^3.1.0, the version
already present in the workspace lockfile, so no new transitive
packages) plus `@types/plist` — Node's stand-in for plistlib.

Verified: tests-js `npm run check` passes (typecheck + 9/9 tests), and
a mutation run (removing audio-input from the inherit plist) turns
both regression tests red.
2026-07-15 17:24:12 -04:00
ethernet
2f3007ff51 test: port JS/package.json invariant tests from Python to vitest
The CI change classifier routes package.json / package-lock.json /
.ts/.tsx changes to the frontend lane, not the Python lane. Four
Python tests asserted about these JS-side artifacts, so a PR touching
only those files would skip the Python suite — the regression goes
green on the PR and red on main (where the classifier fails open).

Ported all four to vitest so they run in the correct CI lane:

  tests/test_package_json_lazy_deps.py
    → apps/desktop/electron/package-json-lazy-deps.test.ts
    (camofox is lazy, agent-browser is eager, lockfile clean)

  tests/test_desktop_electron_pin.py
    → apps/desktop/electron/desktop-electron-pin.test.ts
    (electron dep is exact, matches build.electronVersion, lockfile agrees)

  tests/test_assistant_ui_tap_compat.py
    → apps/desktop/electron/assistant-ui-tap-compat.test.ts
    (@assistant-ui cluster shares one tap version + semver helper)

  tests/test_dashboard_sidecar_close_on_disconnect.py
    → web/src/lib/chat-sidebar-session-params.test.ts
    (sidecar session.create opts into close_on_disconnect + profile)

The ChatSidebar test was regex-matching .tsx source text (the
source-reading anti-pattern). Extracted sidecarSessionCreateParams()
from the component's effect into an exported pure function so the
test calls real code instead of pattern-matching a string.

Verified: 8 electron tests + 76 web tests pass; both typecheck clean.
2026-07-15 17:24:12 -04:00
Siddharth Balyan
56ab9951b1
fix(dashboard): add MCP auth to profile builder (#65163)
* fix(dashboard): add MCP auth to profile builder

* fix(dashboard): preserve MCP rejection error contract

* feat(dashboard): refine profile MCP picker
2026-07-16 02:05:04 +05:30
ethernet
ef7aabd3d1 ci: add ci-reviewed label gate for CI-sensitive files 2026-07-16 01:42:02 +05:30
xxxigm
8b209e0dd7 test(agent): cover think scrubber leak after flush-then-retry 2026-07-16 01:07:29 +05:30
Teknium
bd7e480236
fix(compression): give fallback candidates their own timeout budget + escalate repeat-timeout cooldowns (#65143)
Fixes #62452. Two amplifiers turned one slow auxiliary route into a
per-turn multi-minute stall:

1. Fallback candidates inherited the exact effective_timeout the primary
   was called with. When the primary's deadline was short (tuned or
   already burned), an independently healthy fallback died on the same
   clock — the reporter's 163k-token compression needed ~90s on the
   fallback and got the primary's 30s, every turn. fallback_chain
   entries may now declare their own 'timeout' (seconds); both fallback
   candidate call sites (sync + async) resolve it via
   _fallback_entry_timeout, label-scoped so only configured-chain
   candidates are affected. No entry timeout → task-level timeout,
   preserving existing behavior.

2. A session whose transcript structurally cannot be summarized within
   the deadline re-attempted every 60s, re-burning the full timeout on
   every subsequent turn. Consecutive timeout-class failures now
   escalate the cooldown 60s → 300s → 900s (capped); any successful
   summary or session reset clears the streak. Timeout classification
   takes precedence over the streaming-closed 30s rung ('timed out'
   also matches _is_connection_error) and now recognizes the SDK's
   'Request timed out.' phrasing.

Fail-safe behavior is unchanged: all messages are preserved when every
candidate fails; the cooldown only spaces out retries.
2026-07-15 12:28:09 -07:00
Teknium
5f1991bf6b
fix(gateway): import PairingStore in _start_secondary_profile_adapters (#65118)
The served-profiles block in _start_secondary_profile_adapters references
PairingStore, but the class's only import in gateway/run.py is method-local
inside __init__ — so the reference raised NameError at runtime, silently
swallowed by the enclosing try/except ('could not record served_profiles').
Result: multiplexing gateways never created per-profile pairing stores, and
authz pairing checks for secondary profiles fell through to the global
whitelist. Also masked the served_profiles runtime-status write.

One-line fix (local import alongside write_runtime_status) + regression
tests that drive the real method and assert the stores materialize,
verified red without the import and green with it.

Surfaced during the profile-routing sweep by @CocaKova's PR #61689, which
included the same fix as part of a larger feature.
2026-07-15 12:11:16 -07:00
kshitij
2b6897f982
fix(computer-use): target Linux app windows reliably (#63725)
* fix(computer-use): target Linux app windows reliably

Resolve app filters through the canonical cua-driver MCP app metadata and join running app PIDs back to windows. Preserve an exact selected window across capture_after, support direct capture by pid/window_id, and send the active window ID for coordinate pointer actions on Linux.

Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
Co-authored-by: grimmjoww578 <willies578@gmail.com>
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>

* fix(computer-use): address review on PR #63725

Address three review comments from @f-trycua:

1. type_text, press_key, and hotkey now carry _active_window_id and
   fail closed when it is missing. Previously they sent only the PID,
   so CUA Driver fell back to the first window for that PID — input
   could reach the wrong window in multi-window apps.

2. Coordinate scroll x/y are now capability-gated behind
   input.scroll.coordinates. CUA Driver 0.7.1 Linux schema rejects
   x/y on scroll; omitting them when the driver doesn't advertise
   support avoids the schema rejection while still routing via
   window_id.

3. Windows are sorted by z_index descending (higher = front, per CUA
   Driver semantics) instead of ascending. Null z_index (Wayland) is
   coerced to 0 in _ingest_windows so it doesn't crash the sort and
   sorts to the back instead of being selected as the capture target.

---------

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
Co-authored-by: grimmjoww578 <willies578@gmail.com>
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-07-16 00:34:20 +05:30
kshitijk4poor
58033baba2 fix(plugins): classify stale-call circuit breaker as failover, not retry
The cross-turn stale-call circuit breaker (_check_stale_giveup in
agent/chat_completion_helpers.py) raises a RuntimeError when the
provider has been unresponsive for N consecutive stale attempts.
This error was classified as FailoverReason.unknown (retryable=True,
should_fallback=False), causing the retry loop to burn all max_retries
against the same dead provider — each retry hitting the circuit breaker
instantly with zero network overhead — before fallback was attempted.

Add a classification rule (section 7b in classify_api_error) that
recognizes the stale-breaker RuntimeError by its signature phrases and
classifies it as FailoverReason.timeout with retryable=False,
should_fallback=True. This makes the retry loop skip retries and go
straight to fallback provider activation on the first hit.

Test: test_stale_breaker_runtime_error_triggers_fallback_not_retry
2026-07-16 00:27:39 +05:30
Jason
c346f018d4 fix(gateway): evict stale-self-heal agent cache entries pointing at dead sessions
The #54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The #54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the #54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. #54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked #54878, #54947, #59580, #59597, #61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying #54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [#54947 intact],
cross-process invalidation preserved [#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
2026-07-16 00:11:22 +05:30
Siddharth Balyan
e0e7cfa673
fix(dashboard): add HTTP MCP authentication (#65146) 2026-07-16 00:02:09 +05:30
Frowtek
170959d80a fix(gateway): sanitize sender-name prefix in shared multi-user sessions
GatewayRunner._prepare_inbound_message_text() interpolated
source.user_name — the platform-supplied, user-settable display name —
directly into the message text of every turn in a shared multi-user
session: f"[{source.user_name}] {message_text}". Shared sessions are the
default for any threaded conversation (thread_sessions_per_user defaults
to False) and apply to any group with group_sessions_per_user=False, so
no special configuration is needed to reach this path.

An unescaped display name containing embedded newlines could therefore
masquerade as a new markdown section (a fake "## Override" heading)
inside the live conversation the model reads on every turn — the same
indirect-prompt-injection vector gateway/session.py's
build_session_context_prompt() already guards against for the identical
user_name field via _format_untrusted_prompt_value(), which was never
applied to this sibling call site.

Add neutralize_untrusted_inline_text() alongside the existing helper in
gateway/session.py: it collapses embedded newlines/control characters to
a single inert line without JSON-quoting, so inline "[Name] message"
formatting is preserved byte-for-byte for the common case (unlike
reusing _format_untrusted_prompt_value directly, which would add visible
quote marks to every sender prefix). Wire it into the sender-prefix
construction in gateway/run.py.
2026-07-15 23:48:34 +05:30
Teknium
3b15afafa2 fix(bedrock): region-scoped model picker + geo-aware recommendations (#28156)
Bug 2 of #28156: the picker offered us./global. inference profiles to
EU-region endpoints (unroutable — AWS rejects them regardless of
credentials) and _RECOMMENDED hardcoded us.anthropic.* ids, so non-US
pickers pinned profiles their endpoint can't invoke.

- bedrock_model_routable_from_region(): geo-prefixed profiles are only
  offered in their own geography (full AWS prefix set incl. apac./jp./
  ca./sa./me./af.); bare ids and global.* pass everywhere; unknown
  region shapes hide nothing.
- Recommendations match geo-agnostically on the base model id, so an EU
  picker pins eu.anthropic.claude-sonnet-4-6; in-region geo profiles
  sort above global.* for the same model (addresses the global.*-first
  ordering complaint from the issue thread).
- Dedup generalized from (us., global.) to all profile prefixes.
2026-07-15 09:59:38 -07:00
Teknium
b6f749af94 test(bedrock): monkeypatch-based bearer routing tests + SigV4 regression
Rewrites the cherry-picked test to import os locally and monkeypatch the
resolver seams instead of patching bedrock_adapter internals; adds the
inverse assertion (no bearer -> AnthropicBedrock SDK path preserved).
2026-07-15 09:59:38 -07:00
JiaDe-Wu
5e6a0d9eea fix(bedrock): streaming fallback to Converse API + image base64 decode + bearer token routing
Three fixes for the Bedrock Claude path:

1. Streaming fallback: When AnthropicBedrock SDK raises 'Unexpected event
   order' (SDK misparses Bedrock error events as message_start), auto-switch
   to native Converse API for the rest of the session instead of failing
   after 3 retries.

2. Image base64 decode (#33317): data URL payloads were passed as base64
   strings to source.bytes, but boto3 re-encodes at the wire layer. Now
   decoded to raw bytes before passing to Converse API.

3. Bearer token routing (#28156): Users with AWS_BEARER_TOKEN_BEDROCK are
   now routed through Converse API regardless of model, since the
   AnthropicBedrock SDK only supports SigV4 signing.

3 new tests. 121 bedrock_adapter tests passing.
2026-07-15 09:59:38 -07:00
Teknium
fcdc10a0f3 fix(moa): reject half-filled MoA saves at the API boundary and hold desktop autosave until slots are complete
Follow-up hardening on top of #64158 (@DavidMetcalfe):

Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
  counterpart to the deliberately tolerant normalize_moa_config(). Flags
  half-filled slots, empty reference lists, recursive moa slots, naming the
  exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
  and returns 422 with the specific problems instead of silently swapping the
  user's preset for hardcoded defaults (#64156). Also declares
  fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
  client round-trips no longer erase hand-set values.

Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
  autosave is deferred (not repaired) while any slot is half-filled, and
  flushes once the model pick completes the edit. Mid-edit UI state is never
  repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
  autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
  model shows the 'Model' placeholder instead of vanishing.

Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).

Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.

Fixes #64156
2026-07-15 09:50:08 -07:00
Teknium
647520f83e fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters
Follow-ups on the salvaged #20096 profile-routing feature:

- _profile_name_for_source now returns None unless gateway.multiplex_profiles
  is on. Routing stamps source.profile, which namespaces session/batch keys,
  but the profile-scoped agent run only activates under multiplexing — without
  the gate, configured routes with multiplexing off split batch/session keys
  into agent:<profile> while the agent still ran from agent:main.
- Widen the profile-aware _text_batch_key fix from Discord to every adapter
  that builds batch keys via build_session_key (telegram, whatsapp, matrix,
  feishu, wecom, weixin) — routing is platform-generic, so the batch-key
  namespace fix must be too.
- Downgrade the no-route-matched log from INFO to DEBUG (fired on every
  unrouted inbound message).
- GatewayConfig.to_dict(): serialize profile_routes as plain dicts
  (ProfileRoute dataclasses are not JSON-safe).
- Docs: correct the 'independent of multiplexing' claim in
  docs/profile-routing.md (routing requires multiplexing), fix the
  platform-only specificity row (0, not 1), and document profile_routes in
  website/docs/user-guide/multi-profile-gateways.md.
- Tests: pin the multiplex gate (routes ignored when off, active when on,
  build_source end-to-end stays in agent:main when off).
2026-07-15 09:50:05 -07:00
Burgunthy
c29ab7b9fe test(gateway): adapter→session-key integration for Discord + Telegram
Completes the review's ask for "adapter-to-session-key integration coverage
for Discord and a non-Discord platform" on #20096.

Drives a concrete adapter's real BasePlatformAdapter.build_source with an
injected gateway_runner, asserts the matched route's profile is stamped on
the source, and that build_session_key scopes the key under agent:<profile>:
(versus the shared agent:main: namespace). Covers Discord and Telegram — the
Telegram case is the bug-#2 path that previously fell through to default.
Adds a regression anchor: without gateway_runner, profile stays None and the
key lands in agent:main (the silent fallback the fix removes for non-Discord).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Burgunthy
f58e4622cf fix(gateway): profile routing — conjunctive matching + universal gateway_runner
Addresses hermes-sweeper review on #20096.

Problem 1 (profile_routing.py): route matching returned True on a chat_id
hit before the guild_id constraint was consulted, so a route declaring both
guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND)
semantics — every declared discriminator must hold; hierarchical parent_chat_id
matching is preserved. Added a regression test for the guild+chat case.

Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter
pre-declared the attribute, and only Discord did — so build_source never called
_profile_name_for_source for Telegram/Feishu/Slack/etc., despite the
platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made
the plugin-registry injection unconditional, so profile routing now reaches
every platform. Added non-Discord (Telegram) resolution coverage and an
injection-inheritance test.

Also adds docs/profile-routing.md documenting gateway.profile_routes
(matching rules, specificity, profile isolation) — requested in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Burgunthy
d7993ab178 fix(config): honor gateway.multiplex_profiles nested form
load_gateway_config only forwarded the top-level multiplex_profiles
key, ignoring the nested gateway.multiplex_profiles form. The latter
is what `hermes config set gateway.multiplex_profiles true` writes,
so users who ran that command got multiplex_profiles=False silently
— no warning, no fallback, profile_routes just stopped matching.

Loader now checks the top-level key first, falls back to the nested
gateway section, and only then defaults to False. Same precedence is
applied to other nested-form keys (profile_routes already did this).

Tests cover: top-level honored, nested honored (regression test for
the silent-fallback bug), default False, top-level overrides nested.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Burgunthy
a1d6654264 fix(gateway): read adapter token from config for fingerprint check
_adapter_credential_fingerprint only looked at adapter.token directly,
but Discord (and similar) adapters store the bot token on their config
sub-object, not on self. Every Discord adapter in a multiplexed
gateway therefore returned None, the same-token conflict check was
silently skipped, and N adapters all polled the same bot token —
producing a per-message race where whichever adapter won the GIL
answered the user.

Adds a config-token fallback (token, then bot_token) so the check
actually fires for config-backed adapters. Direct adapter.token
still takes precedence when both exist.

Tests cover: config-backed token produces a fingerprint, distinct
tokens produce distinct fingerprints, direct token wins over config,
config without token attributes returns None.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Burgunthy
9166d727b8 fix(profile-routing): remove dead forum cache, warn on missing profile
Three follow-ups to the initial routing PR after code review:

1. Remove dead forum-post hierarchy cache. The `_forum_post_cache`,
   `register_forum_post()`, and `resolve_forum_channel()` were never
   wired up — no caller in the codebase. Discord's adapter already
   sets `parent_chat_id` to the immediate parent (forum channel for a
   forum post), so the existing `self.chat_id == parent_chat_id`
   branch in `matches()` handles forum posts correctly without a
   cache. The hierarchical-resolution branch in `matches()` and the
   bounded-LRU infrastructure are removed.

2. Fix docstring specificity numbers (8 → 14, 4 → 6) and rewrite the
   "Hierarchical matching" section to describe the actual one-level
   parent_chat_id behavior. Removed unused `OrderedDict` and `Set`
   imports.

3. Warn loudly when a routed profile doesn't exist on disk. Previously,
   a typo in `profile_routes` (e.g. `crypto-tradr`) silently fell back
   to the global HERMES_HOME, causing the message to read the default
   profile's memory/credentials with no signal to the operator. Now
   emits a `logger.warning` with the profile name, source identifier,
   and the fallback reason. Bare-exception path also gets `exc_info`.

Tests:
- test_profile_routing.py: +2 tests verifying forum post matching via
  direct parent_chat_id (covers the case the removed cache was meant
  for). 31 total, all pass.
- test_profile_resolution.py: NEW, 12 tests covering resolution order
  (source.profile > routing > active > default), missing-profile
  warning, exception handling, and routing consultation. All pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
Burgunthy
5e65f6d79f feat(gateway): add profile-based routing for inbound messages
Adds gateway.profile_routes config that routes specific Discord
guilds/channels/threads (and other platforms) to different profiles.
The routing engine uses hierarchical specificity matching
(thread > channel > guild) with bounded LRU caching for forum post
resolution.

Routing result is stamped on source.profile by BasePlatformAdapter
.build_source() at inbound time. When gateway.multiplex_profiles is on,
the existing _profile_runtime_scope machinery picks up source.profile
and runs the whole turn inside the profile's HERMES_HOME — so memory,
skills, config, and secrets all resolve to that profile automatically.
No new isolation code is added; this PR only adds the routing decision
layer on top of the existing multiplexing infrastructure.

Configuration:

    gateway:
      multiplex_profiles: true
      profile_routes:
        - name: server-default
          platform: discord
          guild_id: "GUILD_ID"
          profile: server-profile
        - name: special-channel
          platform: discord
          guild_id: "GUILD_ID"
          chat_id: "CHANNEL_ID"
          profile: channel-profile

When multiplex_profiles is off, profile_routes is ignored (no behavior
change for single-profile gateways).

Tests: 29 unit tests covering specificity scoring, hierarchical
matching, path-traversal validation, and config parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:50:05 -07:00
liuhao1024
6a8e7069b4 fix(agent): dedup codex incomplete interims on visible content
Two consecutive incomplete assistant interims with identical visible
content (content + reasoning) are collapsed even when opaque provider
state (encrypted reasoning item ids, message item phases) drifts per
continuation — previously that drift defeated dedup and caused message
storms (#52711). The latest opaque payload is written onto the existing
message in place, so continuation replay still uses fresh provider
state.

Salvage note: the original PR also made the 3-retry continuation cap
cumulative per turn (no reset on progress). That half is intentionally
dropped — a legitimate long turn alternating incomplete/progress would
hard-fail at 3 cumulative, changing semantics beyond the reported bug.
2026-07-15 09:49:11 -07:00
Teknium
09b6d22dfe fix(gateway): harden hidden-incomplete detection against the sentinel final_response
Follow-up to the salvaged #51657: the conversation loop returns the
retry-exhaustion sentinel as BOTH final_response and error, so the
original detector (which required final_response to be falsy) never
fired on real exhaustion turns — the sentinel text was delivered
verbatim into the channel, exactly the #51628 poisoning vector. Detect
the sentinel echo, blank it before empty-response normalization, and
never suppress a turn whose final_response is genuine model text.
Also: dedupe-guard mock fix in the test fixture (has_platform_message_id
must return False, not a truthy MagicMock) and two guard tests
(real answer never suppressed; interrupted/failed never classified).
2026-07-15 09:48:24 -07:00
LeonSGP43
08015c3a8f fix(gateway): suppress hidden-only incomplete codex turns 2026-07-15 09:48:24 -07:00
Sk
fe1ab949fd fix(agent): treat Codex incomplete content filter as refusal
Map Codex Responses status=incomplete with incomplete_details.reason=content_filter to finish_reason=content_filter so the existing refusal/fallback path runs instead of burning incomplete continuation attempts.
2026-07-15 09:47:37 -07:00
Teknium
704bbcca80 feat(compressor): keep image URLs and unknown-part markers in summarizer serialization
Follow-up on @AlexFucuson9's cherry-picked multimodal flattening:

- http(s) image parts render as '[image: <url>]' so the summary keeps a
  referenceable handle after compaction (base64 data: URLs still
  collapse to '[image]' — no reusable reference, and leaking them is
  the bug being fixed)
- unknown part types (document, future shapes) render as '[<type>]'
  instead of being silently dropped
- test docstrings corrected: the original crash premise is stale
  (redact_sensitive_text grew str() coercion in #52147); the live bug
  is repr-noise + base64 leakage into the summarizer input
2026-07-15 09:47:04 -07:00
AlexFucuson9
868a2f7d17 fix: handle list content in _serialize_for_summary for multimodal messages
msg.get('content') can return a list of parts for multimodal messages
(containing text, images, etc.). The old code passed this list directly
to redact_sensitive_text(text: str), which raised AttributeError on
list.replace(), causing context compression to fail entirely for any
session with attached images.

Fix: detect list content and extract text parts before redacting.
Image parts are replaced with '[image]' placeholder.
2026-07-15 09:47:04 -07:00
Teknium
07be37d996 fix(auxiliary_client): warn once + regression tests for bootstrap version skew
Follow-up on the salvaged fallback: silent degradation is how #64333 went
unnoticed (jobs dead on arrival, only errors.log knew). Warn once with a
resync hint, and cover both the skewed and healthy paths with tests.
2026-07-15 07:47:18 -07:00
HexLab98
2388e0687b test(input): preservation regressions and prompt.submit boundary (#62557)
Add cases for mid-string markers, trailing punctuation, and insufficient
tail repeats; verify prompt.submit passes sanitized text to run_conversation.
2026-07-15 07:39:42 -07:00
Teknium
7c954969b7
fix(auxiliary): route direct-create aux callers through call_llm (#65029)
* fix(auxiliary): route direct-create aux callers through call_llm (#35566)

Five callers (kanban_decompose, kanban_specify, profile_describer, and
goals.py's judge + draft-contract) built raw clients via
get_text_auxiliary_client() and passed extra_body=get_auxiliary_extra_body()
— which only returns Nous portal tags and ignores
auxiliary.<task>.extra_body from config.yaml entirely. That was the
remaining half of #35566 after the call_llm path was fixed.

Routing them through call_llm(task=...) gives each caller the full
auxiliary contract for free: task extra_body, the reasoning_effort
shorthand, transient retries, provider-profile projection, and fallback
chains. goal_judge gains a DEFAULT_CONFIG block (it had none — its
provider/model overrides silently didn't exist as documented keys).

get_auxiliary_extra_body() now has zero non-test callers; kept for
plugin back-compat.

Fixes #35566.

* test: migrate kanban dashboard + CLI specify mocks to call_llm

Two more consumers of specify_task mocked the old
get_text_auxiliary_client symbol (missed in the first sibling sweep —
they live outside tests/hermes_cli's kanban files): the dashboard
plugin's /specify endpoint tests and the /kanban slash-command E2E.
Same migration as the rest: mock call_llm at the source, no-provider
now surfaces via the LLM-error branch.
2026-07-15 07:39:17 -07:00
Epoxidex
8662254ab2 fix(ollama): emit top-level reasoning_effort=none on /v1/chat/completions (#25758)
Ollama's /v1/chat/completions silently ignores extra_body.think (it only
honours it on /api/chat — ollama/ollama#14820), so agent.reasoning_effort:
none never actually disabled thinking on OpenAI-compatible Ollama routes.
Emit the top-level reasoning_effort='none' field (which Ollama respects)
alongside think=False (kept for proxies and the native /api/chat path).

The PR's second half (propagating reasoning_config to the background-review
fork) already landed on main via agent/background_review.py, so only the
provider-profile change is salvaged here, resolved onto the current
GLM/effort-aware profile.

Salvaged from PR #29820 by @Epoxidex.
2026-07-15 06:38:28 -07:00
Teknium
31dcd68bfa test: cover Anthropic aux extra_body passthrough (Bug B scope + exclusions)
Five tests for the salvaged #37217 Bug B fix: vendor-field passthrough,
reasoning-key + private-key exclusion, merge-over-existing (fast-mode
speed), no-extra_body regression guard, reasoning-only adds nothing.

Live probes against api.anthropic.com informed the exclusion design:
Anthropic strictly validates the request body (unknown keys 400 with
'Extra inputs are not permitted'), so the passthrough forwards only
caller-configured fields and never the OpenAI-shaped reasoning dict
(translated natively) or _-private plumbing keys.
2026-07-15 06:25:10 -07:00
Teknium
9df5f879b4 feat(mcp): enforce exact version pins across the whole MCP catalog
Catalog entries now follow the same supply-chain rules as pyproject
dependencies:

- n8n: install.ref main -> full commit SHA 7a9ae007 (2026-05-23,
  branches/tags can be moved by the upstream owner; SHAs cannot)
- new contract test: every shipped manifest must pin exactly —
  git installs need a 40-char SHA, uvx/npx-style launchers need
  pkg==X / pkg@X with a digit-leading version (rejects bare names,
  ranges, and npm dist-tags like @latest)
- module docstring documents the pin policy (exact version, 2-week
  cooldown)

unreal-engine and linear are http transports (server runs elsewhere)
so there is nothing to pin at the transport layer.

Verified: unpinning blender-mcp in the manifest makes the contract
test fail with a named diagnostic; restoring the pin passes.
2026-07-15 04:56:25 -07:00