Commit graph

2418 commits

Author SHA1 Message Date
devatnull
4bf5b563bd feat: add generic gateway status phrases 2026-07-05 06:29:26 -07:00
devatnull
11627fdcb9 feat(whatsapp): native Baileys polls, clarify-as-poll, locations, and rich inbound metadata
Salvaged from PR #58704 by @devatnull, scoped to the WhatsApp surface:
- bridge_helpers.js: pure, tested extraction of inbound Baileys message
  parsing (quoted text, MIME/filename, PTT vs audio, stickers, contacts,
  reactions, polls, locations, GIF playback metadata)
- native poll primitive: /send-poll endpoint, poll messageSecret caching,
  encrypted vote decryption + aggregation via Baileys
- send_clarify() renders multi-choice clarify prompts as native polls;
  votes flow back through the existing clarify text-intercept
- send_location() + /send-location for native WhatsApp location pins
- structured quoted-reply context (fixes duplicated '[Replying to: ...]'
  rendered both by the adapter and gateway/run.py)
- outbound formatting: markdown *italic* -> WhatsApp _italic_, invisible
  unicode sanitization; execSync -> execFileSync hardening; GIF -> mp4
  gifPlayback conversion with truthful image/gif fallback

Out of scope (deliberately not salvaged from #58704): cross-platform
ordered-delivery machinery in gateway/platforms/base.py, LOCATION: and
hermes:poll response-text directives (no prompt wiring exists yet), and
the unconditional WhatsApp reply-anchor suppression.
2026-07-05 06:27:20 -07:00
devatnull
4be749d151 fix: honor top-level STT transcript echo config 2026-07-05 06:12:49 -07:00
devatnull
406eb719c3 fix: gate interrupt STT transcript echoes 2026-07-05 06:12:49 -07:00
devatnull
bfc5262725 feat: add STT transcript echo toggle 2026-07-05 06:12:49 -07:00
MorAlekss
d577408f3f fix(webhook): reject generic V2 signature missing timestamp instead of falling back to V1 2026-07-05 02:25:38 -07:00
Teknium
cb6c47af08
feat(approvals): /deny <reason> relays denial reason to the agent (port nanoclaw#2832) (#54518)
* feat(approvals): /deny <reason> relays denial reason to the agent

Port from qwibitai/nanoclaw#2832 (reject with reason).

Gateway /deny now accepts an optional trailing reason (/deny <reason>
or /deny all <reason>). The reason rides on the per-session approval
entry through resolve_gateway_approval -> _await_gateway_decision and is
appended to the BLOCKED tool result the agent receives, so a declined
agent can adapt instead of only hearing 'denied'.

Adapted to hermes-agent's synchronous single-command /deny model: no DB
state, no second-message capture step, no migration. Reason is capped at
280 chars and threaded through both the terminal-command guard and the
execute_code guard. Plain /deny and the approve paths are unchanged.

- tools/approval.py: _ApprovalEntry.reason; resolve_gateway_approval gains
  optional reason; _await_gateway_decision returns it; both gateway BLOCKED
  messages include it
- gateway/slash_commands.py: parse leading 'all' + trailing reason
- locales/en.yaml: deny.denied_reason_{singular,plural}
- hermes_cli/commands.py: /deny args_hint '[all] [reason]'
- tests: 3 new (with-reason, all+reason, plain-deny regression)

* fix(ci): localize deny-reason keys across all locales + update interrupt-path assertions

CI surfaced two enforced invariants broken by the deny-with-reason change:
- test_i18n catalog-parity requires every locale to carry the same keys as
  en.yaml with matching placeholders. Added deny.denied_reason_singular/plural
  (with {count}/{reason}) to all 15 non-English locales.
- test_approval_interrupt asserts the exact dict from _await_gateway_decision,
  which now carries a 'reason' key (None on the interrupt/timeout paths).
2026-07-05 02:22:08 -07:00
Teknium
9767e19b60
feat(skills): stacked slash-skill invocations — /skill-a /skill-b do XYZ (#57987)
Inspired by Claude Code v2.1.199 (July 2, 2026): stacked slash-skill
invocations load all leading skills (up to 5), not just the first.

- agent/skill_commands.py: split_stacked_skill_commands() consumes leading
  /skill tokens (stops at the first non-skill token so slash-path arguments
  are never swallowed); build_stacked_skill_invocation_message() composes
  the multi-skill turn reusing the existing bundle scaffolding markers so
  extract_user_instruction_from_skill_message() keeps memory providers
  storing the user's instruction, not N skill bodies.
- cli.py + gateway/run.py: dispatch the stacked path on both surfaces.
- 11 new tests + docs section in skills.md.
2026-07-05 02:20:01 -07:00
luyifan
30479961b8 fix(gateway): tolerate punctuation on silence markers 2026-07-05 02:12:26 -07:00
teknium1
708b57e009 fix(webhook): rate-limit V1 deprecation warning + document V2 signature
- warn once per route instead of on every request (busy senders would
  spam the log)
- document X-Webhook-Signature-V2 / X-Webhook-Timestamp in the webhooks
  user guide

Follow-ups for salvaged #58461.
2026-07-05 01:36:53 -07:00
MorAlekss
70449a4939 fix(security): add timestamp-bound V2 signature for generic webhook replay protection 2026-07-05 01:36:53 -07:00
Teknium
f23026f979
Merge pull request #58536 from NousResearch/salvage/3955-webhook-chunked-limit
fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)
2026-07-05 00:44:37 -07:00
Teknium
485ae54c9f
fix(gateway): pass full transcript to compressor instead of filtered messages (#58551)
Both gateway compression entry points (session-hygiene auto-compress in
run.py; manual /compress in slash_commands.py) filtered the transcript
to user/assistant-only, content-bearing messages before calling
_compress_context. That starved the compressor:

- tool results are usually the bulk of the context, and
  _prune_old_tool_results never saw them
- short filtered histories tripped the protect-first/last early-return,
  so compression became a no-op even on huge sessions
- assistant tool_calls stubs (content=None) were dropped, so even the
  summary lost the tool activity

Pass user/assistant/tool messages through intact, matching what the
agent loop itself feeds _compress_context.

Port of PR #3854 onto current main (the manual-compress handler moved
from run.py to slash_commands.py since the PR branched); regression test
asserts tool messages reach the compressor.

Authored-by: David Zhang <david.d.zhang@gmail.com> (@Git-on-my-level)

Co-authored-by: David Zhang <david.d.zhang@gmail.com>
2026-07-05 00:43:48 -07:00
Sahil Shubham
6c7960cfa0 fix(whatsapp_cloud): honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS
The Cloud setup wizard and docs tell operators to set
WHATSAPP_CLOUD_ALLOWED_USERS (and WHATSAPP_CLOUD_ALLOW_ALL_USERS), but the
adapter DM intake gate only read WHATSAPP_CLOUD_ALLOW_FROM + WHATSAPP_CLOUD_DM_POLICY
(default open, opted-in only via GATEWAY_/WHATSAPP_ALLOW_ALL_USERS). So an
allowlist set via the documented var silently dropped every inbound
(_should_process_message -> None -> HTTP 200, no dispatch, no log line).

- _allow_from also reads WHATSAPP_CLOUD_ALLOWED_USERS
- dm_policy defaults to allowlist when an allowlist is present (else open)
- _open_dm_opted_in() also honors WHATSAPP_CLOUD_ALLOW_ALL_USERS

Explicit DM_POLICY / ALLOW_FROM still win -> backward compatible.
2026-07-05 00:41:34 -07:00
liuhao1024
132bb8a163 fix(yuanbao): restore active singleton after WS reconnect
_do_reconnect() succeeded but never called
YuanbaoAdapter.set_active(adapter), leaving get_active()
permanently returning None after any WS disconnect/reconnect
cycle. This caused cron delivery to silently fail because
_send_yuanbao() checks get_active_adapter() and gives up
immediately when it returns None.

Fix: call set_active(adapter) after successful reconnect,
matching the pattern in connect().

Fixes #58363
2026-07-05 00:41:34 -07:00
luyifan
5b8593266f fix(gateway): cap proxy SSE line buffer 2026-07-05 00:41:34 -07:00
kshitijk4poor
1388cd1c0c fix(logging): thread-safe queue state + bounded hard-exit drain + record copy
Self-review (3-agent + codex) findings on the async QueueListener change:

1. (HIGH) The os._exit shutdown backstop called flush_log_queue(), whose
   stop() joins the listener thread unbounded. If that thread is wedged on
   the rotation lock — the exact failure this change survives — shutdown
   re-freezes. Add drain_log_queue(timeout): stop-only, bounded via a
   throwaway joiner thread. Also release PID/runtime locks BEFORE the drain
   so a slow drain can't strand them.

2. (MED) _log_queue/_queue_listener/_queued_file_handlers were read-modify-
   written without a lock across register/stop/flush/reset; a gateway-init
   race with a plugin/CLI path could leave two live listeners. Guard all
   four globals with a single _queue_state_lock.

3. (MED) _NonFormattingQueueHandler.prepare() enqueued the same LogRecord a
   synchronous handler on the emitting thread may still format/mutate.
   Return copy.copy(record) (preserves msg/args/exc_info for deferred
   RedactingFormatter) to remove the cross-thread mutation race.

E2E-verified: bounded drain returns in ~500ms on a permanently-wedged
listener; 4x20 concurrent flushes single-listener no-crash; args still
format and secrets still redact through the copied record.
2026-07-05 11:44:53 +05:30
kshitijk4poor
ac68a6411a fix(gateway): drain async log queue on os._exit shutdown backstop
The QueueListener change routes rotating file handlers through an
in-memory queue drained on a dedicated thread, with an atexit hook to
flush on shutdown. But _exit_after_graceful_shutdown() uses os._exit,
which bypasses atexit — so on the early-exit and #53107 hard-exit paths
the queued records (including the shutdown reason) were silently lost.

Explicitly flush_log_queue() before os._exit, and correct the now-stale
comment that claimed handlers are synchronous with nothing pending.
2026-07-05 11:44:53 +05:30
yoma
7e8f50a141 fix(gateway): load display config from routed profile 2026-07-04 16:34:16 -07:00
liuhao1024
11b4a21a56 fix(gateway): clear last-resolved-model cache on /new and compression auto-reset
After a config change (e.g. switching model provider), the /new command
must clear the per-session _last_resolved_model cache so the next turn
resolves the model from the updated config instead of falling back to
the stale cached value.

Without this fix, if a transient config-cache miss occurs on the first
post-/new turn, the #35314 recovery path serves the old model from the
cache — the user sees the old model being used even though they changed
config.yaml and explicitly ran /new.

Fix applies to both call sites that reset session model state:
- GatewaySlashCommandsMixin._handle_reset_command (slash_commands.py)
- GatewayRunner compression-exhausted auto-reset (run.py)

Fixes #58403
2026-07-04 16:34:16 -07:00
Tuna Dev
ff4c8172ce fix(gateway): attach credential_pool to session /model overrides
Per-session /model overrides supplied api_key and provider but omitted
credential_pool, so billing rotation never ran on HTTP 402. Wire the pool
on fast override, rehydrate, and apply paths; backfill from provider for
legacy persisted overrides. Regression tests in tests/gateway/.
2026-07-04 16:34:16 -07:00
Gutslabs
2b4ec0082a
fix(api_server): return 413 for oversized chunked bodies
api_server already caps every read via client_max_size (chunked
included), but when the limit tripped mid-read the handler's broad JSON
except turned it into 400 'Invalid JSON'. Catch
HTTPRequestEntityTooLarge in body_limit_middleware and return the
OpenAI-style 413.

Status-code polish extracted from PR #3949 by @Gutslabs — the PR's core
client_max_size change already exists on main.
2026-07-04 15:35:05 -07:00
Gutslabs
ec29590a0f
fix(webhook): enforce body-size limit on chunked requests
The webhook adapter enforced max_body_bytes only via the Content-Length
header; a Transfer-Encoding: chunked request (content_length=None) or a
spoofed small Content-Length bypassed the cap entirely and read the full
body (bounded only by aiohttp's implicit 1 MiB default, above any
operator-configured smaller limit).

- web.Application(client_max_size=max_body_bytes): aiohttp enforces the
  cap on every read path, chunked included
- catch HTTPRequestEntityTooLarge -> 413 (was swallowed into generic 400)
- post-read length re-check as defense in depth
- chunked-upload regression test

Manual port of PR #3955 by @Gutslabs onto current main (handler had
been restructured since); authorship preserved.
2026-07-04 15:33:31 -07:00
teknium1
c6dc7c03c3
Revert "Merge pull request #30179 from NousResearch/feat/iron-proxy"
This reverts commit 8790adc4c6, reversing
changes made to fe5054bccf.
2026-07-04 13:38:59 -07:00
Teknium
8790adc4c6
Merge pull request #30179 from NousResearch/feat/iron-proxy
feat(egress): iron-proxy credential-injection firewall for sandboxes
2026-07-04 13:29:23 -07:00
infinitycrew39
1cc68fc897 fix(gateway): resolve terminal.cwd placeholders per backend and mount mode
Split placeholder TERMINAL_CWD resolution into three cases: local falls
back to MESSAGING_CWD/home; docker without workspace mount leaves cwd unset;
docker with mount enabled preserves an explicit host MESSAGING_CWD path for
terminal_tool's /workspace mapping. Stops leaking host Path.home() into
containers without breaking the mount contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 13:28:47 -07:00
teknium1
14cbbd541e
Merge remote-tracking branch 'origin/main' into iron-proxy-followups
# Conflicts:
#	hermes_cli/config.py
#	hermes_cli/main.py
#	website/docs/reference/cli-commands.md
2026-07-04 03:09:43 -07:00
kshitijk4poor
4651ac64a1 refactor(ssh): extract shared _is_ssh_remote_tilde_cwd predicate
Follow-up to the salvaged SSH-tilde-cwd fix. The predicate
"backend == ssh and (cwd == ~ or cwd.startswith(~/))" was inlined at
each expanduser guard site, which is how the test simulator drifted from
production (it grew an SSH guard on a top-level-alias branch that has no
production counterpart).

- Add tools/terminal_tool._is_ssh_remote_tilde_cwd(backend, cwd) as the
  single source of truth (case/whitespace-tolerant).
- Use it in _get_env_config and the gateway config bridge.
- Test simulator imports the real helper instead of re-implementing the
  predicate; revert the phantom SSH guard on the top-level-alias branch
  (production maps top-level cwd: to a plain env var, not TERMINAL_CWD via
  an SSH-guarded path — that branch tested nothing real).
2026-07-04 14:17:11 +05:30
helix4u
83fb8ec277 fix(ssh): preserve remote tilde cwd 2026-07-04 14:17:11 +05:30
Teknium
19d4174454
feat(gateway): add /sessions search <query> (#57685)
Gateway users can now search resumable sessions from messaging surfaces:
/sessions search <query> (alias: find) matches titles and session ids —
including every title/id in a row's forward compression chain, so a
compressed-away title still surfaces its live tip — plus a
punctuation-normalized variant so 'an94' matches 'AN-94'.

Implemented by generalizing the existing id_query chain-filter in
SessionDB.list_sessions_rich into a combined SQL-level filter (search
stays ORDER BY last-active + LIMIT at SQL level), threading a
search_query through the shared query_session_listing helper, and
teaching parse_session_listing_args to split off a search query.

Search results pass through the existing _resume_row_visible guard
unchanged: origin scoping, admin-only 'all', and the fail-closed
legacy-row posture from the July 1 hardening are preserved exactly.
Over-fetch (50) before the visibility cut so origin-invisible matches
can't starve the page.

Salvages the feature direction of PR #57595 by @GodsBoy with a minimal
implementation that keeps the resume authorization surface untouched.
2026-07-03 13:44:00 -07:00
Maxim Esipov
769469a703 fix: route gateway images by session model override
(cherry picked from commit 7702071c01)
2026-07-03 03:33:06 -07:00
srojk34
16332af60b security(gateway): anchor api_server MEDIA tag resolution to safe paths
_resolve_media_to_data_urls's ad-hoc _MEDIA_TAG_RE matched any bare
token after MEDIA: (no absolute-path anchor) and read the resolved
path directly with no denylist. A relative/traversal path like
MEDIA:../../../../etc/passwd.png slipped through, and any image-
suffixed file the process could read (including under ~/.ssh, ~/.aws,
etc.) was base64-inlined into the API response if its path merely
appeared in the model's own final reply text.

Every other platform adapter's MEDIA: handling already goes through
two shared primitives in gateway/platforms/base.py:
  - MEDIA_TAG_CLEANUP_RE, which anchors the path to ~/, /, or a
    Windows drive letter plus a known deliverable extension.
  - validate_media_delivery_path, which resolves symlinks and rejects
    paths under the credential/system-path denylist.

Reuse both here instead of the local unanchored pattern and naive
Path().expanduser() resolution.
2026-07-03 03:27:47 -07:00
teknium1
741bd9ba42 fix(gateway): resolve queued follow-up session key before native-image buffering
The cherry-picked #48919 fix resolved next_session_key AFTER
_prepare_inbound_message_text had already buffered native image paths
under the stale key. Reorder so the write key and the consume key are
the same resolved key.
2026-07-03 03:21:09 -07:00
LeonSGP43
bb24ac6f20 fix(gateway): preserve queued native image attachments 2026-07-03 03:21:09 -07:00
tt-a1i
e880396488 fix(gateway): key native image handoff by session 2026-07-03 03:21:09 -07:00
Brooklyn Nicholson
52d0d671e7 fix(desktop): poll messaging sessions so platform traffic appears live
Inbound Telegram/WeChat/Discord messages are written by the background
gateway, not the desktop websocket that drives local chats. Without
explicit polling the messaging sidebar and the open transcript stay
frozen until the user manually refreshes.

Desktop:
- MESSAGING_POLL_INTERVAL_MS (10 s): interval poll of the messaging
  session list so new platform sessions surface automatically.
- ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS (5 s): poll the currently-
  viewed messaging transcript and re-hydrate the chat state when the
  FNV-1a signature changes (hash covers role + timestamp + content).
- sameCronSignature now compares lineage_root_id / source / profile /
  preview / message_count / last_active / ended_at so stale previews
  and activity times are no longer silently ignored.
- sessionMatchesStoredId helper de-dups the id / _lineage_root_id check.
- refreshMessagingSessions exposed from useSessionListActions so the
  controller can use it in the poll effect.

Gateway:
- SessionStore._compression_tip_for_session_id: look up the latest
  compression continuation for a session id.
- SessionStore._heal_compression_tip_locked: rewrite a stale entry to
  the compression child before returning it, so a restart or failed send
  no longer leaves the store pinned to the compressed parent.

Co-authored-by: lawyer112 <lawyer112@users.noreply.github.com>
2026-07-03 04:29:22 -05:00
Teknium
64ed99a6e6
fix(webhook): close per-delivery session at the true end of the run (#57423)
The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)

Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.

Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.
2026-07-02 17:39:09 -07:00
kshitijk4poor
201b646d67 fix(gateway): complete on_session_end coverage across all eviction paths
Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:

1. Memory-leak regression for mode='none' sessions. _is_session_expired()
   returns False forever for the 'none' reset policy, so the naive guard would
   never idle-evict those agents — reopening the unbounded-cache leak the idle
   sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
   (a public predicate: will the expiry watcher EVER finalize this session?) and
   gate the deferral on it. mode='none' agents fall through to soft eviction as
   before.

2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
   (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
   _release_evicted_agent_soft, which by design does NOT fire on_session_end.
   If cache pressure evicts a finalizable-but-not-yet-expired agent before it
   expires, the watcher later finds no cached agent and the hook is skipped.
   Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
   finalizable and not yet expired, commit end-of-session extraction via the
   live agent's own (fully-scoped) memory manager using commit_memory_session()
   — extraction WITHOUT provider teardown, so the eviction stays soft and a
   resumed turn keeps working. Skipped for mode='none' (no missed boundary to
   compensate) and expired sessions (the watcher tears those down directly).

This closes #11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).

Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.
2026-07-03 03:46:43 +05:30
Hermes Trismegistus
90b618f48a fix(gateway): keep idle cached agents alive until session actually expires
The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.

Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).

Fixes: #11205
2026-07-03 03:46:43 +05:30
kshitijk4poor
65cb70b8d0 refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close
Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.
2026-07-03 03:26:53 +05:30
Gumclaw
14882bab7e fix(gateway): close webhook sessions on delivery completion so prune can reap them
Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.

SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.

Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.

Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.
2026-07-03 03:26:53 +05:30
Brooklyn Nicholson
5a6720b884 fix(desktop,tui-gateway,zai): stop thinking-off from reverting to medium
A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:

- _session_info reported reasoning_effort "" for disabled reasoning,
  indistinguishable from unset — the desktop adopted it after the first
  turn, wiping its sticky "thinking off" pick so every later chat
  reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
  config.yaml, so every desktop model-menu selection (preset.effort ??
  'medium') clobbered the user's configured value. Now session-scoped
  like the messaging gateway's /reasoning, landing on
  create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
  to "" by every loader's `str(x or "")`, silently re-enabling thinking.
  parse_reasoning_effort now treats False/"false"/"disabled" as
  {"enabled": False}; loaders (tui gateway, gateway, cli, cron,
  delegate) pass the raw value through. The desktop config reader also
  crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
  defaults to thinking ON server-side — so disabling reasoning was a
  silent no-op on direct Z.ai, the actual token burner. The profile now
  emits extra_body.thinking {"type": "enabled"|"disabled"} for
  thinking-capable GLM models, mirroring the DeepSeek profile.

Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.
2026-07-02 15:23:47 -05:00
David Zhang
30e947e0a0 feat(gateway): persist per-session /model overrides across gateway restarts
Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.

- gateway/session.py: SessionEntry.model_override field with
  sanitize_model_override() (allowlist: model/provider/base_url) applied on
  both serialization and deserialization; SessionStore.set_model_override /
  get_model_override accessors. reset_session() already creates a fresh entry,
  so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
  override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
  command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
  _resolve_session_agent_runtime(); in-memory state always wins, credentials
  are re-resolved per provider (credential-less fallback on failure). Session
  expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
  round-trip, /new clearing, api_key-never-serialized (including tampered
  sessions.json), rehydration + live-state precedence + credential-failure
  degradation.

Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.
2026-07-02 05:51:12 -07:00
Mibayy
4a09b692ec feat(api-server): per-client model routing via model_routes (#3176 salvage)
Adds a no-code routing layer to the OpenAI-compatible API server so one
Hermes deployment can map different API clients to different
model/provider backends. Clients pick a backend by sending a configured
alias as the OpenAI 'model' field; unmatched values fall back to the
global model. Configured aliases are listed by GET /v1/models.

Precedence (highest first): session /model override > model_routes
route > global config. Route provider credentials resolve through
_resolve_runtime_agent_kwargs_for_provider (same seam as
channel_overrides); per-route api_key/base_url are upstream provider
credential overrides — never caller auth, never logged.

Salvaged and rebased from PR #3176 by @Mibayy onto current main.
2026-07-02 05:23:28 -07:00
Mibayy
ce9aa869fc feat(commands): /compact alias + --preview/--dry-run flags for /compress (#3243 salvage)
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / TypeScript (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.
2026-07-02 05:10:31 -07:00
Morgan K
39bff67957 feat(gateway): add 'log' option to display.tool_progress
Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>

tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.
2026-07-02 05:09:38 -07:00
Tarun Ravikumar
2068754d6f feat(api-server): inline MEDIA: image tags as base64 data URLs for remote frontends
Salvage of the surviving piece of #2696 by @tarunravi. The PR's other two
changes (tool progress streaming, SSE None-sentinel fix) were independently
superseded on main by the structured hermes.tool.progress SSE events and the
rewritten queue-drain loop.

Remote OpenAI-compatible frontends can't read server-local file paths, so
MEDIA:<path> tags (browser screenshots, generated images) were dead text.
_resolve_media_to_data_urls() now inlines small (<=5MB) local images as
markdown data URLs across all four response surfaces: chat completions
(non-streaming), session chat, session chat stream final event, and the
Responses API. Non-image, missing, or oversized paths pass through
untouched.
2026-07-02 03:23:44 -07:00
crazywriter1
c43aa6301d feat(gateway): per-channel model and system prompt overrides (Fixes #1955)
- ChannelOverride + channel_overrides; session /model > channel > global
- Thread/parent lookup; YAML bridge for discord.channel_overrides
- Guard channel_overrides when config lacks platforms (test mocks)
- Add sampiyonyus@gmail.com to AUTHOR_MAP
2026-07-02 03:08:11 -07:00
crazywriter1
0010c14e66 feat(gateway): per-channel model and system prompt overrides (Fixes #1955)
- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope
2026-07-02 03:08:11 -07:00
crazywriter1
ebef73f6b8 feat(gateway): per-channel model and system prompt overrides (Fixes #1955)
- config: ChannelOverride + PlatformConfig.channel_overrides

- run: _resolve_model_for_channel, _get_system_prompt_for_channel, channel provider runtime

- tests: channel overrides + config guard for bare runner; conftest asyncio fix; slack/whatsapp warning filters

Made-with: Cursor
2026-07-02 03:08:11 -07:00