Compare commits

...
Author SHA1 Message Date
alt-glitch cc01bcd1da docs: refreshed billing tab screenshots (usage-bar polish) 2026-07-09 05:39:07 +05:30
alt-glitch 17f7e5878e docs: redact card last4 in billing PR screenshots 2026-07-09 00:36:11 +05:30
alt-glitch 20435c57d3 docs: screenshots for desktop billing tab PR 2026-07-09 00:29:36 +05:30
Teknium 8e734810df fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874)
Two client-side halves of the #55578 session split:

1. Submit with a null activeSessionId but a SELECTED stored session now
   resumes that stored session instead of falling straight through to
   createBackendSessionForSend - which silently forked the user's
   conversation into a brand-new session that then got orphan-reaped.
   New-chat drafts (no stored selection) still create sessions as before.

2. prompt.submit recovery now also fires on gateway request timeouts,
   not only 'session not found'. A starved backend loop (the async-
   delegation poller spin) rejects the submit with 'request timed out'
   even though the stored session is fine; previously that surfaced an
   error, left the binding cleared, and set up the split on the next
   send.

Fail-then-pass: 2 new tests fail with production code reverted.
2026-07-08 08:14:31 -07:00
teknium1 ae5e39005b fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry
- run_route_script shells out with subprocess.run (up to 30s timeout); wrap
  the call in asyncio.to_thread so a slow script can't stall every other
  webhook and gateway task on the loop.
- scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged
  contributor commit.
2026-07-08 08:10:55 -07:00
Grace 0cf2e39c41 feat(gateway): add webhook payload filters 2026-07-08 08:10:55 -07:00
teknium1 75efd73961 fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations
Completes the session-binding class on the gateway surface (#55578),
matching the TUI rules:

1. Fail-closed pinning: switch_session() re-opens ended sessions, so
   pinning a completion to a spawning session that has since ENDED
   (user /new, closed rotation) would resurrect a conversation the user
   explicitly ended and inject into it. The injection path now checks
   the pinned row's ended_at first and drops the injection with a
   WARNING when the spawning session is dead or unknown - the result
   stays in the delegation records.

2. /new ends the old conversation's delegations: _handle_reset_command
   calls interrupt_for_session() with the expiring durable session id
   (matching the parent_session_id pin stamped at dispatch) plus the
   routing key as fallback, so a reset can't leave dangling subagents
   whose completions have no live owner.

interrupt_for_session() gains the parent_session_id selector because a
gateway chat's session_key (the platform conversation key) survives a
reset while the session id rotates - key-based matching alone could
never sever a gateway conversation's delegations.
2026-07-08 08:10:28 -07:00
nankingjing d39c62409b fix(delegate): pin async completion to spawning parent session (#57498)
Background delegate_task completions only carried session_key. When multiple
active sessions shared a routing peer, get_or_create_session could recover the
latest ended_at IS NULL row and inject the subagent result into the wrong
session.

Capture parent_agent.session_id at dispatch time, include it on async-delegation
completion events, and pin gateway routing via switch_session when the
synthetic completion message is handled.

Fixes #57498
2026-07-08 08:10:28 -07:00
loongfay b848fcbf11 feat(Yuanbao) optimizes media resource processing speed: parallel download 2026-07-08 08:05:45 -07:00
loongfay 63c4100fe6 perf(yuanbao): bounded-concurrency inbound media resolve 2026-07-08 08:05:45 -07:00
teknium1 58e1647b49 test(cli): update FakeCLI._print_exit_summary for new clear_screen kwarg 2026-07-08 07:59:24 -07:00
Tranquil-Flow efb226b586 fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009)
In single-query (-q) mode, the assistant's final answer was printed and
then immediately erased by _print_exit_summary() — which unconditionally
called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was
present in the session store but invisible in the terminal.

The clear is only needed for interactive TUI teardown (#38928) where
prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter
to _print_exit_summary() (default True, preserving interactive behavior)
and pass False from the single-query call site so the answer stays
visible above the exit summary.

Regression tests cover:
- clear_screen=True (default) calls _clear_terminal_on_exit()
- clear_screen=False skips the clear
- Single-query -q path passes False end-to-end
- Interactive path still clears (preserving #38928)
2026-07-08 07:59:24 -07:00
kyssta-exe e10c8eba00 fix(whatsapp): use windows_detach_popen_kwargs to prevent console window flash on Windows 2026-07-08 07:49:22 -07:00
teknium1 7e3986ae68 fix(tui): route /compress and /compact past the slash worker to command.dispatch
Ported from #60834 (same author) — pending-input routing so clients that
fail the slash.exec->dispatch fallback still reach the new compress handler.
2026-07-08 07:46:08 -07:00
kyssta-exe c0fbee990e fix(desktop): register /compress command in TUI gateway dispatch so Desktop can invoke it 2026-07-08 07:46:08 -07:00
teknium1 65372395eb fix(delegation): positive-proof ownership for the post-turn drain
Extends the salvaged session_key filter with the same fail-closed,
compression-chain-aware ownership gate the poller uses (#55578):

- drain_notifications() accepts an owns_event callback; when provided,
  an async-delegation event is consumed ONLY on positive proof of
  ownership, and a broken callback re-queues (never leaks). Bare key
  equality remains for single-session callers (CLI); no filter remains
  legacy behavior.
- The TUI post-turn drain passes _session_owns_notification_event, so
  it can't adopt another session's (or an orphan's) delegation payload,
  while a post-compression session still claims its own pre-compression
  dispatches - the gap bare key equality left open.
2026-07-08 07:39:52 -07:00
Tony Simons f75f3cd713 fix(delegation): route async delegate_task results back to originating session
The completion event already carries the dispatching session's session_key
(captured at dispatch time in delegate_tool.py:2798), but the delivery
router ignored it — results landed in whatever session was active at
completion time instead of the session that dispatched the subagent.

Changes:
- drain_notifications() in process_registry.py: optional session_key
  filter. Non-matching async_delegation events are re-queued instead of
  consumed, so they remain available for the correct session's drain.
- cli.py process_loop: passes active session_key to drain_notifications()
- tui_gateway/server.py post-turn drain: passes session_key from the
  TUI session dict
- gateway/run.py _build_process_event_source: logs warning when routing
  metadata is unresolvable (previously silent drop)
- Regression tests verifying session-scoped drain filtering

Fixes #58684
2026-07-08 07:39:52 -07:00
waroffchange 5057f03bfd docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) 2026-07-08 07:09:23 -07:00
waroffchange ee0b54e16c docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) 2026-07-08 07:09:23 -07:00
Teknium b64b802131 feat(models): swap curated Tencent Hy3 Preview for GA tencent/hy3, drop owl-alpha (#60943)
- OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and
  tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free
- _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3
- run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3
  (prefix match still covers -preview if pinned)
- model_metadata: register hy3 context length (262144) alongside hy3-preview
- regenerate website/static/api/model-catalog.json
- update tokenhub curated-list tests to the new IDs

The tencent-tokenhub direct provider still serves hy3-preview and is
intentionally unchanged.
2026-07-08 07:09:08 -07:00
teknium1 4b27be1114 fix(delegation): fail-closed orphan handling + session-scoped delegation lifecycle
Two invariants layered on the origin-routing commit (#55578):

1. Fail closed on orphaned async-delegation payloads. The poller's
   belongs-elsewhere check handles events owned by another LIVE session,
   but an event whose owner is gone previously fell through and was
   adopted by whichever poller saw it - injecting one chat's delegation
   output into another chat. Delegation completions are now injected
   only into a session that PROVABLY owns them (origin UI id, or
   session-key/lineage match via the compression chain); unowned
   payloads are dropped from injection with a WARNING (the subagent's
   output is already persisted in the delegation records, so nothing is
   lost). The shutdown drain applies the same rule. Non-delegation
   events keep the historical adopt-orphans behavior.

2. A session's in-flight async delegations end with the session.
   _finalize_session now calls interrupt_for_session(): delegations
   commissioned by the closing UI session are interrupted always;
   key-matched delegations only when the TUI owns the session lifecycle,
   so closing a viewer tab on a live gateway session never kills the
   gateway's own background work.
2026-07-08 07:06:15 -07:00
Dan Schnurbusch aab351bfa6 fix(delegation): route async results to origin session
Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.
2026-07-08 07:06:15 -07:00
fyzanshaik ac6dd598a4 fix(agent): tag desktop chat sessions as desktop
The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface.

Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint.

Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved.
2026-07-08 06:18:18 -07:00
waroffchange 465cbe8bb5 test(tools): add unit tests for skill_gist 2026-07-08 06:17:56 -07:00
teknium1 5413c42f2c chore: add SiteupAgencia to AUTHOR_MAP for #57435 salvage 2026-07-08 06:15:43 -07:00
Lucas Oliveira 98804dbeef fix(tui_gateway): back off notification poller when session is busy
The busy-session branch of _notification_poller_loop re-queued the
completion event and immediately re-polled it with no sleep, spinning
at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as
the session stayed running. This starved the dashboard asyncio loop:
/api/status went from 0.14s to 3-6s with 10s timeouts.

Sleep 0.25s outside history_lock before re-polling, mirroring the
0.1s back-off already used for foreign-session events.
2026-07-08 06:15:43 -07:00
Teknium 7ecc822e11 fix(cron): stop the ticker from stalling forever on a wedged jobs lock (#60703) (#60855)
Three fixes for the silent post-restart ticker stall:

1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a
   30s deadline instead of an unbounded LOCK_EX taken while holding the
   process-wide RLock. On timeout it logs at ERROR and degrades to
   in-process-only locking (the existing fallback path), so a sibling
   process wedged while holding .jobs.lock can no longer freeze every
   cron function - including the ticker's get_due_jobs() and thus the
   heartbeat - forever with zero logging.

2. fire_claim/run_claim freshness checks are bounded on both sides
   (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across
   a restart) was previously fresh forever, making the job permanently
   unfireable and every manual run report 'already being fired'.

3. _execute_job_now distinguishes paused/disabled/missing jobs from a
   genuinely held claim instead of mislabeling them all as 'already
   being fired'.
2026-07-08 05:13:18 -07:00
kshitijk4poor 1192f29450 fix: Z.AI endpoint persist failure must not break URL resolution
Review findings (hermes-pr-review Phase 2, 3-angle):
- _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync,
  atomic replace) and can raise on disk-full/permissions/lock-timeout. The
  persist ran bare in the success path, so a persist failure aborted
  _resolve_zai_base_url() after detection had already succeeded. Wrap the
  persist in try/except: log a warning and still return the detected URL
  (worst case: next start re-probes).
- Readability: stage the payload in a local detected_endpoint instead of
  writing through the stale pre-lock 'state' dict, which is no longer what
  gets persisted.
2026-07-08 17:33:40 +05:30
kshitijk4poor 6eeed3f1e8 fix: don't flip active_provider when caching Z.AI probe result
_save_provider_state() sets auth_store['active_provider'] as a side effect.
The Z.AI endpoint probe runs from credential-pool env seeding for any user
with a Z.AI key in env — persisting the probe cache must not silently make
zai the active provider. Use _store_provider_state(set_active=False).

Follow-up to PR #41201 salvage.
2026-07-08 17:33:40 +05:30
kshitijk4poor c75e1d1b87 chore: add veradim to AUTHOR_MAP for PR #41201 salvage 2026-07-08 17:33:40 +05:30
veradim 832c5f9bc9 Fix slow Z.AI startup by caching auto-detected endpoint to disk
(cherry picked from commit 6ed884933a178d5540f02d80e3fe9e678ca844eb)
2026-07-08 17:33:40 +05:30
Ben Barclay f64e4f4f57 feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730)
For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
2026-07-08 16:55:32 +10:00
teknium1 48788032da fix(tui): derive gateway-owned sources from the Platform enum, not a hardcoded list
The salvaged guard used a hand-maintained frozenset of 14 platform names —
several of which (line, wechat, facebook, imessage, googlechat) aren't
actual Hermes Platform values, while real ones (whatsapp_cloud, feishu,
wecom, dingtalk, qqbot, yuanbao, plugin platforms like irc) were missing.
Resolve the source through gateway.config.Platform instead (built-ins +
registered plugin platforms via _missing_), with an explicit exclusion set
for self-owned/local sources. Adds tests for the guard and both reap paths.
2026-07-07 22:15:36 -07:00
AIalliAI f5ef7ee9da fix(tui): prevent ws_orphan_reap from ending gateway-originated sessions
Guard _finalize_session's db.end_session() call against gateway-owned
sessions (telegram, bluebubbles, discord, etc.).  The TUI is a viewer
for these sessions, not the lifecycle owner.  Unconditionally ending
them in state.db creates a Groundhog Day routing loop: the gateway's
#54878 self-heal detects the stale entry, recovers to the parent
session, context compression splits back to the reaped child, and the
cycle repeats on every inbound message — causing complete conversational
context amnesia.

Fixes #60609
2026-07-07 22:15:36 -07:00
teknium1 ecc6725855 fix(gateway,cron): reconcile #60612 + #60631 onto one drain surface
Keep #60631's get_running_job_ids() snapshot + _active_cron_job_count()
(import-guarded for minimal test doubles) as the single read path, and
retarget #60612's drain tests at it. Drops the redundant
cron_jobs_in_flight() helper so there is one surface, not two.
2026-07-07 22:15:04 -07:00
joaomarcos 8a573bb6e7 fix(cron): stop interrupted jobs from delivering their pre-kill output
Follow-up to the previous commit on #60432. The status-write guard
(_consume_interrupted_flag, checked right before mark_job_run) closes
the false-success bookkeeping gap, but run_one_job delivers its result
BEFORE that check: delivery happens right after run_job() returns,
mark_job_run happens at the very end. A job whose tool subprocess was
killed mid-flight can still produce a plausible-looking final_response
from the truncated output, and that response would reach the user via
_deliver_result before the interrupted flag was ever consulted --
correct status in jobs.json, wrong message already sent.

Adds _is_interrupted(), a non-destructive peek at the same
_interrupted_job_ids set (_consume_interrupted_flag stays as the
consuming, authoritative check right before the status write -- this
needed a peek instead since the flag has to still be visible there).
Checked right after save_job_output, before the deliver_content
decision: if the run looked successful but was flagged interrupted,
force success=False with an explicit interruption message. This
routes delivery through the existing _summarize_cron_failure_for_delivery
path (the same one a real failure already uses) instead of the raw
final_response, so the user gets an honest "this run was interrupted"
instead of a truncated/misleading result.

Testing: 4 new tests in tests/cron/test_shutdown_interrupt.py --
_is_interrupted peek semantics (false/true/does-not-clear, as opposed
to the consuming _consume_interrupted_flag), and the delivery-gate
test itself, which mocks run_job to return a normal-looking success
with a "plausible final response" while the job is pre-marked
interrupted, and asserts _deliver_result receives the failure summary
("This run was interrupted.") instead, with the summarizer's error
argument confirmed to mention the interruption.

Fail-then-pass: reverted cron/scheduler.py only, the 4 new tests fail
(3 on the missing _is_interrupted attribute, 1 -- the delivery-gate
test -- on _summarize_cron_failure_for_delivery never being called,
i.e. the raw response would have gone out); restored, all 16 tests in
the file pass.

Regression: tests/cron/ (683 tests) + test_cron_active_work_drain.py +
test_gateway_shutdown.py + test_shutdown_cache_cleanup.py -- 11
pre-existing failures (Unix file-permission-bit and path-tilde
assertions that don't apply on this Windows dev box), matching the
same set already established as pre-existing in the prior commit's
regression check. Zero new failures.

Continues #60432
2026-07-07 22:15:04 -07:00
joaomarcos 24e9ed73c2 fix(gateway,cron): make shutdown drain visible to in-flight cron work
Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a
standalone AIAgent (run_job/run_one_job), entirely outside
GatewayRunner._running_agents -- the dict _drain_active_agents() and
every other active-work check on that class reads. A gateway shutdown
(/update, /restart, and SIGUSR1 all funnel through the same stop())
could log active_at_start=0 and immediately kill tool subprocesses
while a cron job's terminal command was still running, with no wait
and no indication anything was interrupted.

Real-world impact (from the issue): a scheduled daily briefing cron
job was in flight during /update, its tool subprocess got killed
by the unconditional shutdown cleanup, and the job was never marked
failed -- it simply never completed or delivered, with no error
surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight
during /update reproduced the same pattern: subprocess killed at
+0.22s of drain (active_at_start=0), the job's agent thread continued
in-process and produced a plausible-looking final response from the
truncated tool output, and the scheduler marked the run successful.

Root cause is layered, not a single line:

1. GatewayRunner._drain_active_agents() only waits on _running_agents.
   Cron work was invisible to it, so drain returned instantly whenever
   the only active work was a cron job.
2. Even with visibility, the shutdown's final tool-subprocess kill
   (process_registry.kill_all()) is a global, unconditional sweep with
   no per-job targeting -- a long-running cron job that outlives the
   drain timeout still gets its subprocess killed.
3. cron/scheduler.py had no way to detect that a job's tool subprocess
   was killed out from under it mid-run; the agent thread kept going
   and its eventual (often degraded but plausible-looking) response
   got reported as a normal successful completion.

Fix, three parts:

- cron/scheduler.py: expose get_running_job_ids() (thread-safe
  snapshot of the existing _running_job_ids set, already used to
  prevent double-dispatch) so the gateway can read cron's in-flight
  state without reaching into private module internals.

- gateway/run.py: GatewayRunner._active_cron_job_count() reads that
  snapshot. _drain_active_agents() now waits on
  (_running_agents OR active cron jobs), so a cron-only workload gets
  the same bounded wait chat sessions already get instead of an
  instant active_at_start=0. Shutdown drain logging gains
  cron_active_at_start/cron_active_now fields alongside the existing
  ones (unchanged, for compat).

- cron/scheduler.py: mark_running_jobs_interrupted(reason), called by
  gateway/run.py's _kill_tool_subprocesses() right after
  process_registry.kill_all(), marks every job still in
  _running_job_ids at that instant as failed/interrupted via the
  existing mark_job_run() -- and records the job IDs in
  _interrupted_job_ids BEFORE writing, so run_one_job()'s own
  eventual completion for the same run (racing in its own thread)
  checks that flag and skips its normal write instead of clobbering
  the interrupted status with a false "ok" produced from the
  now-truncated tool output. This does not attempt to correlate a
  killed PID to a specific job ID (process_registry tracks PIDs, not
  job IDs) -- any job still dispatched at the moment of a forced kill
  is treated as interrupted, matching the existing coarser precedent
  set by _interrupt_running_agents(), which interrupts every entry in
  _running_agents on a drain timeout without per-agent correlation
  either.

Deliberately out of scope (flagged in the issue as a separate,
lower-priority concern): startup-time reconciliation of cron runs that
started but never reached a terminal status.

Testing:

- tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids
  snapshot semantics, mark_running_jobs_interrupted marking/no-op/
  partial-failure behavior, and -- the core race guard -- run_one_job
  skipping its own last_status write (both the success path and the
  exception path) when the shutdown path already marked the run
  interrupted, with a control test proving ordinary un-interrupted
  completions are unaffected.

- tests/gateway/test_cron_active_work_drain.py (9 tests):
  _active_cron_job_count reading cron state and failing closed (0) if
  the cron module is unavailable; _drain_active_agents waiting for an
  in-flight cron job the same way it waits for chat sessions, timing
  out if the job outruns the window, and leaving existing chat-session
  drain behavior unchanged; a full runner.stop() integration test
  (drain-timeout path) proving mark_running_jobs_interrupted actually
  fires with the right job ID when a tool subprocess is force-killed,
  plus a no-op control when nothing cron-related is in flight.

- tests/gateway/test_shutdown_cache_cleanup.py: added
  _active_cron_job_count() to that file's hand-rolled _FakeGateway test
  double, which stop() now calls -- without it those 8 pre-existing
  tests AttributeError (caught by fail-then-pass below, not a
  production bug).

Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21
new tests fail (fixture/attribute errors -- the feature doesn't exist
yet); restored, all 21 pass.

Regression check: ran the full plausibly-affected surface --
tests/gateway/{test_gateway_shutdown,test_restart_drain,
test_restart_notification,test_restart_redelivery_dedup,
test_restart_resume_pending,test_restart_service_detection,
test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker,
test_external_drain_control,test_session_state_cleanup,
test_update_command,test_update_streaming}.py plus tests/cron/ (944
tests) -- against a clean upstream/main checkout and against this
branch. Diffed the two FAILED lists: identical, 20 pre-existing
failures on both sides (Windows-locale/cp1252 file-encoding issues and
Unix-permission-bit assertions that don't apply on this Windows dev
box), zero new failures, zero fixed-by-accident. The 8
test_shutdown_cache_cleanup.py failures found mid-development were
from the _FakeGateway gap above, fixed in the same commit and
confirmed clean on the final rerun (diff against baseline: exit 0).

Fixes #60432
2026-07-07 22:15:04 -07:00
HexLab98 e6077af279 test(gateway): cover cron drain during gateway shutdown (#60432) 2026-07-07 22:15:04 -07:00
HexLab98 862aee4956 fix(gateway): drain in-flight cron jobs before shutdown tool kill
/update and other shutdown paths only waited on gateway session agents,
so active cron tool work was killed immediately in final-cleanup while
the scheduler could still mark the job successful (#60432).
2026-07-07 22:15:04 -07:00
teknium1 a208b7eeb4 chore: add AUTHOR_MAP entry for neoguyverx (PR #60526 salvage) 2026-07-07 22:14:33 -07:00
teknium1 6695640c1d fix(tools): make the YAML write gate syntax-only so multi-doc/tagged YAML isn't refused
safe_load() raises ComposerError on multi-document streams (k8s manifests)
and ConstructorError on application-defined tags (CloudFormation !Sub,
Ansible !vault) — both valid YAML syntax. Now that the linter's verdict is
a fail-closed write gate, those false positives would refuse legitimate
writes outright. Switch to yaml.parse() (scanner+parser only), which still
catches real syntax failures.
2026-07-07 22:14:33 -07:00
Neo Guyver 2e1982f83d Fail closed on invalid JSON/YAML/TOML writes instead of writing then reporting
write_file() previously called _atomic_write() first and only ran the
JSON/YAML/TOML/Python syntax check afterward as an informational lint
delta -- a parse failure never set the top-level `error` key, so a
corrupt structured-data write still landed on disk (and file_tools.py's
files_modified gating, which keys off `error`, silently reported it as
a successful modification).

Move the in-process syntax check for JSON/YAML/TOML ahead of
_atomic_write() and refuse the write outright on a parse failure: no
temp file, no rename, nothing touches disk, and the result carries a
top-level `error` so callers correctly see it as unmodified.

Deliberately scoped to _FAIL_CLOSED_INPROC_EXTS (JSON/YAML/TOML), not
all of LINTERS_INPROC -- .py is excluded because this codebase's own
test fixtures (TestPatchReplacePostWriteVerification et al.) write
arbitrary non-Python text through *.py paths purely to exercise
write-mechanics; a hard block there broke 3 previously-passing tests
during development. Python keeps its pre-existing non-blocking
lint-delta report.

Adds tests/tools/test_write_file_syntax_gate.py: invalid JSON/YAML/YML/
TOML refused with nothing written (new file) and nothing modified
(existing file); valid JSON/YAML still written byte-for-byte; a
non-linted extension with garbage content is unaffected; invalid Python
is confirmed NOT hard-refused (still just reported).
2026-07-07 22:14:33 -07:00
ethernet 4d7f8ade3e feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225)
* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)

pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.

- hermes_cli/config.py: shared is_unsupported_install_method() /
  format_unsupported_install_warning() helpers so the wording and docs
  link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
  warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
  the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
  'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
  install_warning; applyRuntimeInfo + the live session.info event fire
  a snoozable warning toast via a new reportInstallMethodWarning(),
  mirroring the existing backend-contract-skew toast pattern. i18n
  strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
  Homebrew banner test, and two tui_gateway session_info tests
  (install_warning present for pip, absent for git).

* fix(nix): make `hermes` in developement environment actually work

install modules as editable overlay with uv

* feat: print install method when running --version

* fix: correct detect install method when running from a subtree
2026-07-07 21:13:19 -07:00
Teknium 9de9c25f62 chore: release v0.18.2 (2026.7.7.2) (#60651)
Publish to PyPI / Build distribution 📦 (push) Has been cancelled
Publish to PyPI / Publish to PyPI (push) Has been cancelled
Publish to PyPI / Sign and attach to GitHub Release (push) Has been cancelled
2026-07-07 20:11:08 -07:00
Teknium c30c9753b6 fix(whatsapp): unpin Baileys from git commit, use published 7.0.0-rc13 (#60643)
The April 2026 pin to WhiskeySockets/Baileys#01047deb existed only to
pick up the abprops bad-request fix (Baileys PR #2473) before it was
released. That fix shipped in v7.0.0-rc11 (May 2026); our pinned commit
is now 48 commits behind rc13.

The git pin forced npm to clone the repo and compile Baileys from
TypeScript source on every fresh install (~3 min), which blew past the
dashboard pairing flow's timeout. Registry install takes ~3s.

Validation: all 9 bridge.js imports present in rc13, bridge.native.test.mjs
passes (13/13), live bridge boot renders pairing QR against real WA servers.
2026-07-07 19:49:26 -07:00
Teknium f9eca7e15f chore: release v0.18.1 (2026.7.7) (#60595)
Publish to PyPI / Build distribution 📦 (push) Has been cancelled
Publish to PyPI / Publish to PyPI (push) Has been cancelled
Publish to PyPI / Sign and attach to GitHub Release (push) Has been cancelled
2026-07-07 18:14:48 -07:00
Shannon Sands 4f620a0bbc Add WhatsApp dashboard pairing flow 2026-07-07 17:45:51 -07:00
teknium1 6015ee5d2a fix: pass profile-scoped SessionDB to _session_latest_descendant in dashboard chat PTY resume
The chat PTY launch path landed on main after PR #50558 and still called
_session_latest_descendant() with the old one-arg signature. Open the
requested profile's state DB (matching the REST endpoint) so profile-scoped
resume resolves descendants in the right database.
2026-07-07 17:44:44 -07:00
Shannon Sands 543f069093 Fix dashboard chat model profile scoping 2026-07-07 17:44:44 -07:00
Ben Barclay 75de0057bc feat(gateway): GATEWAY_MULTIPLEX_PROFILES env override for multiplex flag (#60589)
The connector now depends on the single multiplexed gateway for per-profile
relay routing, so hosted deployments need to FORCE multiplexing on regardless
of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only,
which a user could leave unset or flip off.

Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the
existing config key — the same 'config.yaml is canonical, env is the operator
override' pattern the Telegram/Signal require_mention bridges use:

  env (recognized token) > config.yaml (top-level or nested gateway.*) > False

- gateway/config.py: _env_multiplex_profiles_override() resolves the env var
  tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized
  → None (fall through to config). Blank is deliberately None, not False, so a
  provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in
  (the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer
  (run.py, session.py via self.config) sees the resolved value.
- hermes_cli/gateway.py: the named-profile-start guard
  (_guard_named_profile_under_multiplexer) reads config.yaml directly, so it
  gets the SAME env precedence — otherwise env-forced multiplex would leave the
  guard blind and someone could start a conflicting per-profile gateway that
  double-binds a bot token. Env-forced-on trips the guard even with no
  config.yaml key; env-forced-off disables it over a config opt-in.

Tests: full 3-tier precedence in test_config.py (incl. the discriminating
env-overrides-config cases + the empty/whitespace/unrecognized fall-through
trap + resolver tri-state), mutation-verified (flipping precedence fails
exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py.

Force-on is safe on a single-profile instance: session keys stay byte-identical
(agent:main) and the _run_agent wrapper installs the per-turn secret scope, so
the fail-closed get_secret() path is satisfied.
2026-07-08 00:34:34 +00:00
teknium1 d9a4b5a5e5 fix: validate memory provider names before filesystem lookup and setup commands
Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.
2026-07-07 17:27:54 -07:00
Shannon Sands 4b184cbe54 Add dashboard memory provider switching 2026-07-07 17:27:54 -07:00
Ben Barclay 4e4a69cbf7 feat(relay): carry routed profile from the connector wire source (#60586)
The multiplex machinery already routes an inbound message to a profile via
SessionSource.profile (build_session_key namespacing + the per-turn
config/credential scope in SessionStore._resolve_profile_for_key). But the
relay path never populated it: _event_from_wire rebuilt the SessionSource
field-by-field and dropped any 'profile' the connector sent, so a
Team-Gateway (connector + relay) message could not be routed to a specific
profile the way the /p/<profile>/ HTTP prefix and per-credential polling
adapters already can.

Stamp source.profile from the wire payload in _event_from_wire. This is the
last missing link for NAS-driven per-profile routing over the relay in
multiplex mode; the connector populating the field ships separately
(gateway-gateway contract adds the optional wire field).

Back-compat: absent 'profile' → None → legacy agent:main namespace,
byte-identical to today for every single-profile gateway.
2026-07-08 00:20:23 +00:00
Ben Barclay 8d66e78844 feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585)
The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.

Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
  surface and now ride the always-public status body, surviving the auth
  gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
  recon and stays gated alongside hermes_home / config_path / env_path /
  gateway_pid / gateway_health_url.

The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.
2026-07-08 00:20:13 +00:00
Teknium 5633fa19b8 fix(dashboard): advertise truecolor to the embedded chat TUI (#60576)
Headless/hosted deploys run the dashboard server without COLORTERM in
the process environment, so chalk inside the PTY-spawned TUI child
downgraded every skin hex color to the xterm 256 palette — the default
skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F,
salmon red) and the gold caduceus rendered red/yellow on fresh cloud
instances. Local launches never reproduced it because the operator's
interactive terminal leaks COLORTERM=truecolor into the server env.

xterm.js always renders 24-bit RGB, so the dashboard PTY child should
always advertise truecolor: backfill COLORTERM=truecolor in
_resolve_chat_argv via setdefault (an explicit operator value wins).

Verified with a clean-env PTY probe of the real TUI binary:
no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173);
with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50.
2026-07-07 17:17:51 -07:00
Shannon Sands bf7639138e Use read-only config loader and honor HERMES_IGNORE_USER_CONFIG in delegation config 2026-07-07 17:17:38 -07:00
Shannon Sands 0263f1d12e Fix delegation config precedence 2026-07-07 17:17:38 -07:00
TekniumandMarco-Olivier Lavoie 6ca3d701fc fix(gateway): only session-discover channel targets for connected platforms (#60574)
Session-based channel discovery resurrected historical origins for
platforms with no connected adapter, exposing stale send_message
targets that can no longer deliver. Gate both the enum loop and the
plugin-registry loop on the live adapter set.

Surgical reapply of the channel-directory portion of PR #25959 (branch
was 6.5k commits stale; the text-batching delay changes bundled there
were dropped - separate concern, defaults have since been retuned on
main).

Co-authored-by: Marco-Olivier Lavoie <marcolivier@gmail.com>
2026-07-07 17:04:32 -07:00
teknium1andooovenenoso db9e3e4ef9 docs(discord): troubleshoot silent fail-closed denials
Docs portion of PR #57067: 'bot connects but never replies' section
pointing at the gateway.log warning and the allowlist/policy knobs.

Co-authored-by: ooovenenoso <120500656+ooovenenoso@users.noreply.github.com>
2026-07-07 17:01:08 -07:00
yungchentang 3e7ade418d fix(discord): explain fail-closed allowlist default
Log a one-shot structured warning when Discord denies traffic because
no allowlist/policy is configured, and correct the setup wizard's
inverted warning text. The fail-closed default itself is unchanged.

Fixes #58682.
2026-07-07 17:01:08 -07:00
BROCCOLO1D c3808cfc14 fix(discord): honor pairing grants for message auth 2026-07-07 17:00:58 -07:00
Teknium 551f00109d docs(sessions): unify export docs under one overview section (#60554)
Restructures the five parallel export sections into a single 'Export
Sessions' section: a format table (jsonl/md/qmd/html/trace + --only
user-prompts), one shared-filters paragraph covering all formats, and
per-format subsections nested beneath. EN + zh-Hans.
2026-07-07 16:29:27 -07:00
Tranquil-Flow 8a726e91ba fix(tools): enable platform-native toolsets when their composite is explicitly configured (#35527)
When a user explicitly configures a platform with its native composite
(e.g. platform_toolsets.discord: [hermes-discord]), the discord and
discord_admin toolsets were silently stripped by _DEFAULT_OFF_TOOLSETS
even though the composite contains those tools. The strip could not tell
an explicit composite opt-in apart from the unconfigured default.

Track whether the platform was explicitly configured and, when it was,
exempt toolsets that are both default-off and platform-restricted to the
current platform from the strip. Only discord/discord_admin are affected
(the sole entries in both _DEFAULT_OFF_TOOLSETS and
_TOOLSET_PLATFORM_RESTRICTIONS). Unconfigured and empty-list platforms
keep the security default-off behaviour.
2026-07-07 16:27:28 -07:00
Teknium b062083d0a feat(dashboard): report profile + gateway topology in /api/status (#60537)
/api/status (loopback/insecure binds only) now includes:
- profiles: every profile on the host (default + named)
- gateway_mode: none | single | multiple | multiplex
- gateways: one entry per live gateway with the host ports its
  port-binding platforms listen on, plus served_profiles when the
  default gateway is multiplexing

Ports resolve from each profile's config.yaml (top-level platforms:
wins over gateway.platforms:, matching load_gateway_config precedence)
with adapter defaults as fallback. Topology enumeration runs in an
executor so the profile scan + process-table probes stay off the event
loop, and the whole block is gated behind the same loopback-only split
as hermes_home/gateway_pid so gated binds leak nothing new.
2026-07-07 16:13:03 -07:00
teknium1 838d50495f fix(mcp): guard POSIX-only kill primitives in stdio watchdog for the Windows footgun linter
signal.SIGKILL / os.killpg don't exist on Windows. The watchdog is only
spawned on POSIX (wrap site gates on os.name), but guard via getattr with
a plain terminate/kill fallback so an accidental Windows import can't
AttributeError.
2026-07-07 15:16:00 -07:00
teknium1 2d4fd1d52f test(mcp): unblock recycle-reconnect test from the parked self-probe wait
The salvaged test predates the parked-server self-probe
(_PARKED_RETRY_INTERVAL, landed on main after the PR branched): after the
final failed retry, run() parks in a real asyncio.wait that the patched
asyncio.sleep doesn't cover, stalling the test 300s. Signal shutdown once
the retry budget is exhausted so the park exits immediately.
2026-07-07 15:16:00 -07:00
teknium1 a6203839b4 docs(mcp): document idle_timeout_seconds / max_lifetime_seconds recycle keys + handshake-bound note 2026-07-07 15:16:00 -07:00
teknium1 bae3954f4f chore(release): map rainbowgore + thestudionorth in AUTHOR_MAP for MCP leak salvages 2026-07-07 15:16:00 -07:00
harjoth ea0b42c43a Handle minimal MCP server fakes 2026-07-07 15:16:00 -07:00
harjoth 6c731fe591 Recycle idle MCP stdio servers 2026-07-07 15:16:00 -07:00
teknium1 86c5febdd1 fix(mcp): watchdog wrap after OSV preflight + forward SIGTERM to child group
Two fixes on top of the salvaged parent-death watchdog:
- Apply the watchdog wrap AFTER the OSV malware preflight so the check
  inspects the real npx/uvx package instead of the python wrapper
  (the wrap previously made the preflight a silent no-op for every
  stdio server).
- The real server runs in its own process group under the watchdog, so
  the graceful-shutdown killpg no longer reached it; the watchdog now
  forwards SIGTERM/SIGINT to the child's group, keeping wedged servers
  killable on clean shutdown.
2026-07-07 15:16:00 -07:00
Sage 5089c84dbf fix(mcp): reap orphaned stdio MCP children on ungraceful parent death
A stdio MCP server (e.g. `npx -y mcp-remote <url>`) is spawned as a direct
child of the Hermes process. Existing teardown (MCPServerTask.shutdown() /
_kill_orphaned_mcp_children()) reaps it correctly on a clean exit, but a
kill -9 / crash / force-quit of the Hermes process skips that path entirely
-- the child (and its own descendants, e.g. mcp-remote's spawned node
process) is orphaned and keeps running. Repeated ungraceful restarts pile up
N orphaned processes racing to hold the same upstream SSE session, producing
errors like 'Invalid request parameters' on legitimate reconnects.

macOS/Linux have no portable equivalent of prctl(PR_SET_PDEATHSIG) at the
Python subprocess level, so this adds a thin supervisor
(tools/mcp_stdio_watchdog.py) that:
  - execs the real command as its own child in its own process group
  - passes stdin/stdout/stderr through untouched (MCP stdio protocol
    talks directly over those streams)
  - polls the original spawning PID with the same orphan-detection
    algorithm already proven in tui_gateway/slash_worker.py (ppid
    comparison + psutil creation-time guard against PID reuse)
  - SIGTERM-then-SIGKILL's the child's process group the moment the
    original parent is gone

Wired into _run_stdio via a new _wrap_command_with_watchdog() helper,
POSIX-only (matches the existing killpg-based cleanup's platform scope),
fails open (any error resolving pid/create-time falls back to the
unwrapped command) so this can never be the reason a working MCP server
stops starting.

Verified: reproduced the exact orphan scenario standalone (fake parent
process spawns watchdog + fake long-running MCP child, kill -9 the fake
parent, confirm the watchdog reaps the child within its poll window with
zero leaked processes). Updated test_mcp_tool_issue_948.py's resolved-path
assertion to check the watchdog-wrapped command instead of the raw
resolved binary. Full test_mcp_tool.py + test_mcp_stability.py +
test_mcp_tool_issue_948.py suite: 232 passed. Full -k mcp sweep across the
whole test tree: 1003 passed, 2 skipped, 0 failed.
2026-07-07 15:16:00 -07:00
teknium1 4638f3b433 fix(mcp): widen #59349 handshake bound to HTTP transports + cancel abandoned start() task
Sibling sites of the same bug class as the salvaged stdio fix:
- SSE, streamable-HTTP (new + deprecated API) initialize() calls are now
  bounded by the same connect_timeout, so an endpoint that accepts the
  connection but never answers the handshake cannot park the run() task
  forever.
- start() now cancels its ensure_future'd run() task when the caller's
  connect timeout cancels start() itself — the orphaned-task leak was
  the root mechanism behind #59349, and this closes the class for any
  future pre-ready hang.
2026-07-07 15:16:00 -07:00
rainbowgits 1f6836cd81 fix(mcp): bound stdio initialize handshake to stop subprocess/FD leak
A stdio MCP server that never completes `initialize` (e.g. emits a
non-JSON-RPC frame and then blocks on stdin) leaks a child process plus its
stdio pipes/pidfd on every discovery-retry cycle — unbounded, until the
gateway hits EMFILE and every new open()/spawn fails (#59349).

Root cause (confirmed by instrumenting the live repro, and different from the
issue's own hypothesis): the spawned child IS captured in `new_pids`, so the
report's "new_pids empty at finally" guess is not it. The real cause is that
`session.initialize()` hangs forever on the garbage stream. `connect_timeout`
only bounds the caller's `.result()` wait on the foreground thread — it does
NOT cancel the `_run_stdio` coroutine on the background MCP loop. So the
coroutine is stuck at `await session.initialize()` permanently, its cleanup
`finally` never runs, the child is never reaped, and it stays invisible to the
orphan-reaper (whose `_orphan_stdio_pids` set never gets populated).

Fix: wrap `session.initialize()` in `asyncio.wait_for(..., connect_timeout)`
so a stalled handshake fails instead of hanging. The TimeoutError unwinds
through the SDK context managers (closing the child's stdin -> EOF -> exit)
and lets the existing `finally` reap any straggler. Cross-platform — no
signals/pgid/proc.

Scope: stdio only. The HTTP path has the same `await session.initialize()`
shape but spawns no subprocess (so it can't cause this leak) and already has
httpx transport timeouts.

Verified: the reporter's repro goes from unbounded growth to draining to zero;
added a hermetic regression test (fake transport whose `initialize()` hangs,
asserts the connect is bounded by connect_timeout) that fails on the pre-fix
code and passes on the fix; 566 existing MCP tests pass; ruff clean.

Repro confirmed on macOS (pipe FDs); the Linux-specific pidfd growth in the
report should be equivalent — the reporter offered to validate on Linux.

Closes #59349
2026-07-07 15:16:00 -07:00
teknium1 743c116fb2 fix(mcp): unify reconnect orphan reaping + move off the event loop
Merge the two cherry-picked reap call sites into one unscoped sweep at
the top of _run_stdio (the unscoped sweep is a superset of the
per-server one), and run it via asyncio.to_thread so the 2s
SIGTERM->SIGKILL escalation cannot stall the shared MCP event loop.
2026-07-07 15:16:00 -07:00
yoma f99e9f0d27 fix(mcp): reap stdio orphans before reconnect 2026-07-07 15:16:00 -07:00
liuhao1024 086596ca2b fix(mcp): reap orphaned subprocesses before spawning new ones on retry
When an MCP stdio subprocess fails to connect (token expiry, port
contention, timeout), the run() reconnect loop retries with backoff.
Each retry calls _run_stdio() which spawns a new process pair, but the
previous failed pair was only detected as orphaned (added to
_orphan_stdio_pids) — never actually killed.  This caused rapid zombie
accumulation: 5 failed attempts × 2 procs each = 10 orphans competing
for the same port.

Add a _kill_orphaned_mcp_children() call at the top of _run_stdio(),
before the _snapshot_child_pids() baseline, so any orphans from prior
failed attempts are reaped before a new subprocess is spawned.

Fixes #57355
2026-07-07 15:16:00 -07:00
Benn DentonandClaude Opus 4.8 79f4f78fa4 feat(chat): persist attach token, reconnect on transient close
ChatPage sends ?attach=<localStorage token> so /chat reattaches to its
live PTY across refresh. onclose: 4410=process-exit (session ended),
4409=superseded (quiet), else transient -> auto-reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn DentonandClaude Opus 4.8 c3d2be073a feat(pty): periodic reaper wired into dashboard lifespan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn DentonandClaude Opus 4.8 e10e4bca82 feat(chat): reattach /api/pty sessions via ?attach= token
Keep-alive path when ?attach=<token> is present: PTY outlives the socket
via PTY_REGISTRY, reattaches on reconnect. No token = unchanged legacy
pump (_legacy_pump). detach (not close) on disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn DentonandClaude Opus 4.8 41166bbe0d feat(pty): PtySessionRegistry with reap + capacity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn DentonandClaude Opus 4.8 e5ac169c28 feat(pty): PtySession drain/attach/detach with EOF close 4410
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn DentonandClaude Opus 4.8 0ecfbc9890 feat(pty): RingBuffer for keep-alive output capture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
teknium1 d5a5ea8640 chore: add doncazper to AUTHOR_MAP 2026-07-07 15:14:30 -07:00
teknium1 117f49b7d4 fix(approval): wire gateway notify round-trip into the plugin escalation gate
_run_approval_gate's gateway branch only queued via submit_pending, so
plugin-escalated approvals never sent the interactive embed+buttons on
Discord/Telegram/Slack (#59413) - the user was never notified and the
action stayed silently blocked. Mirror check_dangerous_command's path:
when a session notify callback is registered, run the blocking
_await_gateway_decision round-trip (redacted payload, once/session/
always persistence, deny/timeout produce definitive BLOCKED outcomes);
fall back to submit_pending only when no callback exists.

Fixes #59413.
2026-07-07 15:14:30 -07:00
doncazper 36308f0667 feat(plugins): pass approve rule keys to approval gate 2026-07-07 15:14:30 -07:00
Teknium f304f41266 fix: harden explicit-provider gate for stale env-seeded pool entries + non-desktop picker opt-ins
Follow-up on the #56966 salvage:

- is_provider_explicitly_configured(): an env-seeded credential-pool entry
  only counts as explicit while its env var still resolves to a usable
  secret. A stale auth.json entry left behind after the user deletes the
  var no longer keeps the provider in the picker forever (#55790).
- TUI modelPicker + dashboard ModelPickerDialog/api.getModelOptions pass
  include_unconfigured=true explicitly, preserving their full-universe
  setup-affordance behavior now that the backend defaults to the
  configured subset.
- desktop lib/model-options.ts routes explicit_only through the shared
  requestModelOptions() helper (added on main after the PR branched).
- regression tests for ambient (gh_cli) pool sources, explicit manual/
  device-code sources, and stale vs live env-seeded entries.
2026-07-07 15:12:54 -07:00
Ronald Reis a0b14a3126 chore: map Ronald contributor email 2026-07-07 15:12:54 -07:00
Ronald Reis 37a4cf9000 fix: limit desktop model pickers to explicit providers 2026-07-07 15:12:54 -07:00
Teknium 0e04d14209 feat(sessions): trace export + HF upload via 'sessions export --format trace' (#60507)
* feat(trace): upload sessions to HF Agent Trace Viewer

Salvage trace upload as a smaller CLI-first feature: deterministic Claude Code JSONL export, fail-closed redaction, lazy Hugging Face dependency, and no gateway slash-command wiring.

* chore(trace): drop external porting references from docstrings

Describe the trace-upload design in Hermes' own terms.

* feat(sessions): fold trace upload into 'sessions export --format trace'

Integrates the HF Agent Trace Viewer exporter (PR #36145) onto the
unified export surface instead of a separate 'hermes trace' subcommand:

- --format trace: Claude Code JSONL to stdout/file, or one
  <id>.trace.jsonl per session for filtered bulk export; defaults to
  the most recent session when no --session-id/filters given.
- --upload pushes to the user's private HF traces dataset (--public to
  opt out of private); reads HF_TOKEN with guided setup when missing.
- traces are secret-redacted by default (force mode); --no-redact opts
  out after review; redaction failure blocks export (fail closed).
- hermes_cli/trace.py + subcommands/trace.py removed; agent/trace_upload.py
  is the single engine. Docs EN + zh-Hans; 4 new CLI tests.
2026-07-07 15:12:49 -07:00
teknium1 5e51b123f3 feat(mem0): add self-hosted mode to the setup wizard
The salvaged SelfHostedBackend made self-hosted servers reachable via
mem0.json / MEM0_HOST, but the setup wizard still offered only Platform
and OSS — exactly the gap users hit (Discord report: 'At memory setup
there's only 2 options'). Adds a third wizard mode:

- interactive picker: Platform / Self-hosted server / Open Source
- non-interactive: hermes memory setup mem0 --mode selfhosted
  --host http://... [--api-key ...] [--dry-run]
- host -> mem0.json (behavioral), API key -> .env as MEM0_API_KEY
  (secret), optional key for AUTH_DISABLED servers
- best-effort reachability check against the server, non-fatal
- README + memory-providers docs updated with the wizard path
2026-07-07 14:07:16 -07:00
Que0x b4289200ba fix(web-server): close OAuth token TOCTOU by writing 0o600 atomically
`_save_anthropic_oauth_creds` wrote the Anthropic OAuth token file with
`os.replace(tmp, path)` followed by a post-hoc `chmod(0o600)`. Between the
rename and the chmod the token file existed at the default umask (0o644 on most
hosts) — a window in which another local user could read the access/refresh
tokens.

Write via `utils.atomic_json_write(..., mode=0o600)`, which creates the temp
with mode 0o600 *before* any content is written, fsyncs, atomically replaces,
preserves the existing file's owner, and cleans up its temp on failure. This
matches the `atomic_json_write(mode=0o600)` call already used elsewhere in this
module for the credential-pool write, and #56644's owner preservation.

Tests updated for the new mechanism, plus a check that the write goes through
`atomic_json_write(mode=0o600)` (mutation-verified).
2026-07-07 13:54:07 -07:00
teknium1 b1500af277 fix: restore cli-config.yaml.example from main (stale-branch version leaked into salvage) 2026-07-07 13:46:51 -07:00
teknium1 94b4ac118a chore: add alex107ivanov to AUTHOR_MAP 2026-07-07 13:46:51 -07:00
alex107ivanov e0176cbd47 feat(discord): optionally mention approval owners on exec prompts
Opt-in discord.approval_mentions (config.yaml, bridged to
DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric
allowlist entries to exec-approval prompts, with a scoped
AllowedMentions override (users only). Default off - no surprise
pings. Reapplied onto the content-mirror layout from #60245: mentions
prepend to the visible content block and its truncation budget.

Original implementation from PR #39719; commits arrived bot-authored,
re-attributed to the contributor.
2026-07-07 13:46:51 -07:00
teknium1 f76899facf feat(sessions): wire html + prompt-only formats into 'sessions export'
Salvage follow-up integrating PR #30481 (@simplast) and PR #57683
(@catbearlove1-lang) into the unified export surface:

- --format html: standalone self-contained HTML transcript (single
  session or multi-session with sidebar), works with all shared filters
  and --redact; requires a file output path.
- --only user-prompts: prompt-only export (jsonl records or md sections)
  via the shared session_export renderer; the separate export-prompts
  subcommand from the original PR is subsumed by this flag.
- AUTHOR_MAP entries for both contributors; docs EN + zh-Hans.
2026-07-07 13:29:58 -07:00
doer b172e03c20 feat(cli): filter internal session_meta messages from HTML export 2026-07-07 13:29:58 -07:00
doer ab07e06521 style(export): restore width: 0 for multi-session flex layout 2026-07-07 13:29:58 -07:00
doer 4bd6fce1c1 fix(cli): fix layout width bug and ensure system prompt header is used 2026-07-07 13:29:58 -07:00
doer 271130af56 feat(cli): expand system prompt by default in HTML export 2026-07-07 13:29:58 -07:00
doer a730156626 feat(cli): redesign system prompt display as dedicated header section 2026-07-07 13:29:58 -07:00
doer 49dd0b1cb5 feat(cli): include system prompts in HTML export 2026-07-07 13:29:58 -07:00
doer a80e5e72bc feat(cli): add standalone HTML session export with sidebar navigation
Implements a professional, standalone HTML export feature for Hermes sessions.

Key changes:
- Adds 'hermes sessions export <file>.html' support to the CLI.
- Implements a dark-mode-first, responsive HTML generator in 'hermes_cli/session_export_html.py'.
- Single session export features a focused, centered 90% width layout.
- Multi-session export adds a fixed sidebar with session switching and real-time search filtering.
- ZERO external dependencies; all styles and JS are embedded for offline portability.
2026-07-07 13:29:58 -07:00
Xue Li b598f8e69b feat: add prompt-only session export 2026-07-07 13:29:58 -07:00
kshitijk4poor 9a322726ae fix(mem0): prune dead get_all, wire rerank config default, warn on MEM0_HOST env override
Review follow-ups on the salvage:

- get_all() pruned from the ABC and all three backends: mem0_list (its
  only caller) was removed by the recall-tuning commit, leaving new,
  tested, unreachable code — including SelfHostedBackend's _MAX_TOP_K
  over-fetch workaround. Tests for it dropped; fake-class stubs remain
  harmlessly. (The #52921 true-total fix lives on in the PR history if
  a lister ever returns.)
- The persisted rerank config key was write-only (setup prompted for it,
  nothing read it). initialize() now parses it into _rerank_default and
  mem0_search uses it when the model doesn't pass rerank explicitly;
  per-call args still win. Guard test added.
- Platform-mode setup now warns when MEM0_HOST is set in the environment:
  the json host-clear can't help there (_load_config seeds host from the
  env var, docs tell users to put it in .env) — the user would silently
  keep routing to the self-hosted server.
- SelfHostedBackend: connect-level retries (httpx.HTTPTransport(retries=2))
  so a single transient blip doesn't count toward the provider breaker;
  transport now injectable and the test helper uses the real __init__
  instead of mirroring it via __new__.
- plugin.yaml description no longer leads with reranking (off by default,
  platform-only); docs em-dash typo fixed.
2026-07-08 01:57:11 +05:30
kshitijk4poor 53edf6f983 fix(mem0): make prompt label + platform setup honor host routing precedence
Follow-up on the salvaged #55614. The PR added host-based routing to
_create_backend (precedence: oss > host > platform) but two sibling surfaces
didn't mirror it:

- system_prompt_block() checked host before oss, so an oss+host config ran
  OSS but told the model it was self-hosted HTTP. Reordered to match routing.
- Platform setup (hermes memory setup mem0 --mode platform) left a stale host
  in mem0.json; since host beats platform, the user kept routing to the
  self-hosted server. save_config merges (no delete), so clear host to ""
  rather than pop() so the merge actually overwrites it.

Adds regression tests for both (mutation-checked).
2026-07-08 01:57:11 +05:30
kartik-mem0 2a14205ff4 feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)
Salvage of #55614 by @kartik-mem0 (mem0 maintainer). Adds a SelfHostedBackend
that talks to a self-hosted Mem0 Docker server over httpx (X-API-Key auth,
/search + /memories routes), gated behind `host`. Also folds in the mem0
research-team recall tuning that rides with it: rerank defaults to false across
all modes, the mem0_list tool is removed (5->4 tools), search guidance is
de-shouted, and self-hosted get_all reports the true stored total (#52921).

Supersedes the self-hosted portion of #52487 (@liuhao1024, first-submitted).

Closes #52478
Fixes #52921
2026-07-08 01:57:11 +05:30
kshitijk4poor b2d6a512d5 fix: normalize string stop + surface dropped stream/tool_choice in Converse shim
Review findings on the salvaged shim: (a) OpenAI callers may pass stop as
a bare string but Converse's stopSequences requires a list — normalize;
(b) call_llm(stream=True) (MoA aggregator) can reach this client and the
shim silently returned a complete response — keep that behavior (the
streaming consumer's got-final-object path downgrades gracefully) but log
it, and log dropped tool_choice, instead of silently ignoring both.
+2 regression tests.

Follow-up to the salvage of #60217 by @xxxigm.
2026-07-08 01:51:05 +05:30
xxxigm e9da629800 test(bedrock): cover auxiliary Converse routing for non-Claude models
Assert gpt-oss Bedrock IDs resolve to BedrockAuxiliaryClient while Claude
IDs keep the Anthropic SDK path, including async mode.
2026-07-08 01:51:05 +05:30
xxxigm fa651375be fix(bedrock): route non-Claude auxiliary models through Converse API
Auxiliary Bedrock resolution always used the Anthropic Bedrock SDK, which
only works for Claude foundation-model IDs. Non-Claude models such as
openai.gpt-oss-20b-1:0 now use a Bedrock Converse adapter, matching the
main agent's bedrock_converse transport.
2026-07-08 01:51:05 +05:30
kshitijk4poor d43863f005 fix: widen stale circuit breaker to non-streaming path + all provider-swap resets
Review findings on the salvaged #60332 breaker, fixed as follow-ups:

- restore_primary_runtime() now resets the streak (third provider-swap
  path; without it a recovered primary was short-circuited before a
  single attempt and could never be re-proven healthy except via /model).
- interruptible_api_call (non-streaming) now carries the same breaker
  (guard at entry, bump on stale_call_kill, reset on success). Quiet-mode
  / subagent / headless sessions — the profile most like #58962's
  unattended 494-failure session — take this path and had the identical
  infinite stale-retry class.
- Partial-stream stub return now resets the streak (chunks were received,
  provider demonstrably responsive).
- Consolidated the triple-duplicated counter arithmetic into shared
  helpers (_stale_streak/_bump_stale_streak/_reset_stale_streak/
  _check_stale_giveup) with one canonical comment block; error message
  now says 'consecutive stale attempts' (the counter counts kills, not
  turns — a single turn can produce several).

4 new tests (restore resets / no-op restore keeps latch / non-streaming
short-circuit / non-streaming success reset).
2026-07-08 01:50:14 +05:30
kshitijk4poor 437052f039 docs: document HERMES_STREAM_STALE_GIVEUP alongside sibling stream knobs 2026-07-08 01:50:14 +05:30
kshitijk4poor 2985d16be0 fix: reset stream-stale breaker on model switch and fallback activation
Follow-up for the salvaged #60332 circuit breaker. The breaker latches:
once the streak trips, interruptible_streaming_api_call raises before any
stream is attempted, so the on-success reset can never run again. The
error text tells the user to switch models and retry — but neither
switch_model() nor try_activate_fallback() cleared the streak, so a
freshly selected healthy provider kept short-circuiting forever (only
/new recovered), and the automatic fallback chain was wedged the same way.

Reset the streak at both swap sites (after a successful rebuild only;
rollback/exhaustion paths keep the latch). 4 tests.
2026-07-08 01:50:14 +05:30
dsad 985e19c110 fix(agent): add cross-turn stream-stale circuit breaker (#58962)
A session wedged against an unresponsive OpenAI-compatible provider can hit the stale-stream detector on every turn and loop forever, burning the full 180s x retries each turn with no response. Issue #58962 reports 494 consecutive failures over 3+ days on a single session.

The streaming retry path already caps retries WITHIN a turn (HERMES_STREAM_RETRIES, default 2) but has no cross-turn cap. Once a session's conversation state makes every turn stale, it retries indefinitely across turns and never notifies the user.

Add a per-session consecutive-stale-stream counter on the agent:
- incremented on every stale-stream kill in the outer poll loop;
- reset to 0 only when a stream actually completes;
- when it reaches HERMES_STREAM_STALE_GIVEUP (default 5), the next turn aborts immediately with a clear, actionable RuntimeError instead of spending 180s x retries again.

This is distinct from the existing stale-stream work (local-provider hard ceiling #44938, backoff/parse-error #60031): those bound a single hung stream, while this bounds repeated cross-turn staleness and surfaces a user-visible error.

Adds tests/run_agent/test_stream_stale_circuit_breaker.py covering the short-circuit, the success-reset, and the increment.
2026-07-08 01:50:14 +05:30
teknium1 ee66ff2790 chore(desktop): drop PR screenshot assets from tree 2026-07-07 13:04:32 -07:00
Adolanium 8ce3c2f991 feat(desktop): add UI scale setting to appearance settings 2026-07-07 13:04:32 -07:00
teknium1 4c3a388cba fix(discord): widen expired-defer handling to /thread slash command
Same 10062 degrade-gracefully pattern as _run_simple_slash: create the
thread anyway, skip the ephemeral followups that need a live
interaction token. Non-expiry defer errors still raise.
2026-07-07 12:42:29 -07:00
Alix-007 b9d9b8aad6 fix(discord): handle expired slash defer interactions 2026-07-07 12:42:29 -07:00
teknium1 acfefa4fda feat(sessions): full prune-filter set + --redact on sessions export
- export now shares _add_session_filter_args / build_prune_filters with
  prune/archive: AGE grammar (5h/2d/1w/ISO) on --older-than plus the full
  filter set (--model, --provider, --min-messages, --min-cost, --branch,
  --chat-id, ...) for both JSONL and md/qmd bulk exports; --dry-run works
  on JSONL too; removes the one-off list_export_candidates helper.
- new --redact flag runs exported message content and tool output through
  force-mode secret redaction (agent.redact) for jsonl, md, and qmd.
- docs EN + zh-Hans updated; new tests for AGE grammar, extended filters,
  filtered JSONL, and redaction.
2026-07-07 12:36:41 -07:00
teknium1 51dd5695ec docs(i18n): add zh-Hans docs for Markdown/QMD session export 2026-07-07 12:36:41 -07:00
teknium1 f3c27e30eb refactor(sessions): fold Markdown/QMD export into 'sessions export --format'
Replaces the parallel export-md subcommand from the salvaged commit with
a --format jsonl|md|qmd flag on the existing export subcommand, so all
session export formats share one surface. Adds AUTHOR_MAP entry.

Salvage follow-up for PR #59542 by @web3blind.
2026-07-07 12:36:41 -07:00
web3blind 91885a32b3 feat(sessions): export sessions to markdown 2026-07-07 12:36:41 -07:00
kshitij 2e42bb2da5 Merge pull request #60448 from kshitijk4poor/chore/59607-comment-precision
docs(gateway): sharpen the cached-agent bypass comment (#59607)
2026-07-08 01:05:25 +05:30
kshitijk4poor 4ca61869cb docs: sharpen bypass comment per review
The live path bypasses the whole _build_gateway_agent_history cleanup
pipeline, not just the replay-cleanup pass — say so precisely.
2026-07-08 00:59:56 +05:30
197 changed files with 18679 additions and 1528 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
watch_file pyproject.toml uv.lock
watch_file pyproject.toml uv.lock hermes
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

+1 -1
View File
@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
| Requisito | Notas |
|-----------|-------|
| **Git** | Con la extensión `git-lfs` instalada |
| **Python 3.11+** | uv lo instalará si falta |
| **Python 3.11–3.13** | uv lo instalará si falta |
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.0",
"version": "0.18.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.0",
"package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}
+18 -4
View File
@@ -1275,6 +1275,12 @@ def restore_primary_runtime(agent) -> bool:
agent._fallback_activated = False
agent._fallback_index = 0
# Reset the stale-call circuit breaker (#58962): the streak measured
# the FALLBACK provider we're leaving; the restored primary deserves
# a fresh stream attempt before the breaker can trip again.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# Undo the fallback's identity rewrite so the prompt is
# byte-identical to the stored copy again (prefix cache match).
from agent.chat_completion_helpers import rewrite_prompt_model_identity
@@ -1992,6 +1998,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
# The breaker's error text tells the user to "switch models ... then
# retry"; without this reset the streak stays latched and the freshly
# selected (healthy) provider would keep short-circuiting before any
# stream is even attempted.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# ── Update _primary_runtime so the change persists across turns ──
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
agent._primary_runtime = {
@@ -2101,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
except Exception as _mw_err:
logger.debug("tool_request middleware error: %s", _mw_err)
# Check plugin hooks for a block directive before executing anything.
# Check plugin hooks for a block or approval directive before executing.
block_message: Optional[str] = None
if not pre_tool_block_checked:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
@@ -2117,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
middleware_trace=list(_tool_middleware_trace),
)
except Exception:
pass
block_message = None
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
try:
+121 -14
View File
@@ -1356,6 +1356,96 @@ class AsyncAnthropicAuxiliaryClient:
self._real_client = sync_wrapper._real_client
class _BedrockCompletionsAdapter:
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
def create(self, **kwargs) -> Any:
from agent.bedrock_adapter import call_converse
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
stop = kwargs.get("stop")
if isinstance(stop, str):
stop = [stop]
if kwargs.get("tool_choice") is not None:
# Converse's toolChoice isn't wired through call_converse();
# no in-tree auxiliary caller passes tool_choice today. Surface
# the drop instead of silently ignoring it.
logger.debug(
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
"Converse shim — ignored.", kwargs.get("tool_choice"),
)
if kwargs.get("stream"):
# Converse streaming isn't wired through this shim. Return a
# complete response instead — call_llm's streaming consumer
# detects a final object and downgrades to non-live output.
logger.debug(
"BedrockAuxiliaryClient: stream=True requested for %s — "
"returning a complete response (Converse shim does not "
"stream); caller downgrades to non-streaming.",
model,
)
return call_converse(
region=self._region,
model=model,
messages=messages,
tools=kwargs.get("tools"),
max_tokens=int(max_tokens) if max_tokens else 4096,
temperature=kwargs.get("temperature"),
top_p=kwargs.get("top_p"),
stop_sequences=stop,
)
class _BedrockChatShim:
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
self.completions = adapter
class BedrockAuxiliaryClient:
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
adapter = _BedrockCompletionsAdapter(region, model)
self.chat = _BedrockChatShim(adapter)
self.api_key = "aws-sdk"
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
def close(self):
pass
class _AsyncBedrockCompletionsAdapter:
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncBedrockChatShim:
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
self.completions = adapter
class AsyncBedrockAuxiliaryClient:
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
self.chat = _AsyncBedrockChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
@@ -1411,6 +1501,8 @@ def _maybe_wrap_anthropic(
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
@@ -4204,6 +4296,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
if isinstance(sync_client, BedrockAuxiliaryClient):
return AsyncBedrockAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@@ -4943,10 +5037,14 @@ def resolve_provider_client(
else (client, final_model))
elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
# SDK (prompt caching, thinking); non-Claude models use Converse API.
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.bedrock_adapter import (
has_aws_credentials,
is_anthropic_bedrock_model,
resolve_bedrock_region,
)
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
@@ -4961,17 +5059,26 @@ def resolve_provider_client(
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
if is_anthropic_bedrock_model(final_model):
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=base_url,
)
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
final_model, region)
else:
client = BedrockAuxiliaryClient(region, final_model)
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
final_model, region)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
+82
View File
@@ -171,6 +171,52 @@ def _env_float(name: str, default: float) -> float:
return default
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
# A session wedged against an unresponsive provider hits the stale detector
# on every call and loops forever (observed: 494 consecutive failures over
# 3+ days, each burning the full stale timeout × retries with no response).
# The agent carries ``_consecutive_stale_streams``: incremented on every
# stale kill, reset only when a call actually completes (or when the
# provider is swapped — switch_model / try_activate_fallback /
# restore_primary_runtime — since the streak measured the OLD provider).
# Past the give-up threshold, calls abort immediately with an actionable
# error instead of re-waiting out the stale timeout.
def _stale_streak(agent) -> int:
try:
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
except Exception:
return 0
def _bump_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = _stale_streak(agent) + 1
except Exception:
pass
def _reset_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = 0
except Exception:
pass
def _check_stale_giveup(agent) -> None:
"""Raise immediately when the consecutive-stale streak is past the
give-up threshold — no network attempt, no stale-timeout wait."""
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
_streak = _stale_streak(agent)
if _giveup > 0 and _streak >= _giveup:
raise RuntimeError(
"Provider has been unresponsive (no response received) for "
f"{_streak} consecutive stale attempts — aborting this call to "
"avoid an indefinite stall. Switch models or start a new "
"session, then retry."
)
def interruptible_api_call(agent, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@@ -186,6 +232,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
provider fallback.
"""
result = {"response": None, "error": None}
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
# of the guard in interruptible_streaming_api_call. Quiet-mode /
# subagent / no-stream-consumer sessions take THIS path, and a wedged
# unattended session here has the same infinite stale-retry class.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
@@ -557,6 +610,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
agent._touch_activity(
f"stale non-streaming call killed after {int(_elapsed)}s"
)
@@ -599,6 +655,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
raise InterruptedError("Agent interrupted during API call")
if result["error"] is not None:
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
@@ -1488,6 +1548,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
"Fallback activated: %s → %s (%s)",
old_model, fb_model, fb_provider,
)
# Reset the stale-call circuit breaker (#58962): the streak measured
# the OLD provider's unresponsiveness. Carrying it over would
# short-circuit the freshly activated fallback before it gets a
# single stream attempt.
_reset_stale_streak(agent)
return True
except Exception as e:
if fb_provider == "nous":
@@ -1896,6 +1961,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
# comment block above ``_stale_streak()``. Raises past the give-up
# threshold instead of burning another stale-timeout×retries cycle.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
@@ -2873,6 +2944,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_close_request_client_once("stale_stream_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
@@ -3002,8 +3076,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
if _content_filter_terminated:
_stub._content_filter_terminated = True
# Partial-stream stub: chunks WERE received (deltas fired), so
# the provider is demonstrably responsive — clear the circuit
# breaker (#58962) just like the full-success return below.
_reset_stale_streak(agent)
return _stub
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
# ── Provider fallback ──────────────────────────────────────────────────
+2
View File
@@ -305,6 +305,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).
"hy3-preview": 262144,
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
"hy3": 262144,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
+11 -16
View File
@@ -743,6 +743,17 @@ PLATFORM_HINTS = {
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"desktop": (
"You are chatting inside the Hermes desktop app — a graphical chat "
"surface, not a terminal. Use markdown freely: it renders with full "
"GitHub flavor (tables, code blocks with syntax highlighting, math "
"via $...$, task lists, blockquote callouts). "
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format ![alt](url) and they "
"render inline as photos."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
@@ -1127,22 +1138,6 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
+34
View File
@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@@ -44,6 +45,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from utils import is_truthy_value
def _ra():
@@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
return base
_TUI_EMBEDDED_PANE_CLARIFIER = (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
"Cmd/Ctrl+L to send it to the chat composer."
)
def _tui_embedded_pane_clarifier(hint: str) -> str:
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
shell env of the desktop's embedded TUI PTY — never on the chat backend).
This is a runtime-surface qualifier, not a config override, so it lives at
the resolution site rather than inside ``_resolve_platform_hint`` (which
is purely the config-platform_hints override applier). Byte-stable for the
cache: called once per session build, deterministically from env state.
Idempotent and empty-safe: re-applying on an already-augmented hint is a
no-op, and an empty input returns empty (we never synthesize the
clarifier without its tui framing).
"""
if not hint:
return hint
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
return hint
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
return hint
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
stable_parts.append(_effective_hint)
+4 -4
View File
@@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
@@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_block_error_type = "tool_scope_block"
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
_block_msg = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
_block_msg = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
+398
View File
@@ -0,0 +1,398 @@
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
shape — one of the three formats the Hugging Face Agent Trace Viewer
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
Docs: https://huggingface.co/docs/hub/agent-traces
Design notes
------------
* **Zero LLM turn.** This is a deterministic export — it never spends a
model call. The ``hermes trace upload`` subcommand calls
:func:`upload_session_trace` directly.
* **Private by default.** Traces can contain prompts, tool output, local
paths, and secrets. The dataset is created private and every text body
is passed through Hermes' secret redactor (``force=True``) unless the
caller explicitly opts out with ``redact=False``.
* **Never raises.** Returns a user-facing status string so command
handlers can echo it straight back to the user. Programmatic callers
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
directly.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_DATASET_NAME = "hermes-traces"
_HERMES_VERSION = "hermes-agent"
_REDACTION_BLOCKED_MESSAGE = (
"Trace upload blocked: secret redaction failed, so the transcript may "
"still contain credentials or other sensitive data. Fix the redactor or "
"rerun with --no-redact only after manually reviewing the transcript."
)
class TraceRedactionError(RuntimeError):
"""Raised when a trace cannot be safely redacted before upload."""
# ---------------------------------------------------------------------------
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
# ---------------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _redact(text: Any, enabled: bool) -> Any:
"""Redact secrets from a string body when redaction is enabled.
Non-strings pass through untouched. Uses Hermes' shared redactor with
``force=True`` so an upload always scrubs known secret shapes even if
the user disabled log redaction globally.
"""
if not enabled or not isinstance(text, str) or not text:
return text
try:
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
except Exception as exc:
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
"""Normalize a message ``content`` field into Anthropic content blocks."""
if content is None:
return []
if isinstance(content, str):
return [{"type": "text", "text": _redact(content, redact)}]
if isinstance(content, list):
blocks: List[Dict[str, Any]] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
elif ptype in ("image_url", "image"):
# Keep a placeholder; the viewer renders text turns and we
# don't want to inline base64 blobs into a trace.
blocks.append({"type": "text", "text": "[image omitted]"})
else:
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
else:
blocks.append({"type": "text", "text": _redact(str(part), redact)})
return blocks
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
blocks: List[Dict[str, Any]] = []
if not isinstance(tool_calls, list):
return blocks
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
name = fn.get("name") or tc.get("name") or "tool"
raw_args = fn.get("arguments")
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args) if raw_args.strip() else {}
except (json.JSONDecodeError, ValueError):
parsed = {"_raw": raw_args}
elif isinstance(raw_args, dict):
parsed = raw_args
else:
parsed = {}
if redact:
try:
parsed = json.loads(_redact(json.dumps(parsed), redact))
except (json.JSONDecodeError, ValueError):
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
blocks.append({
"type": "tool_use",
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
"name": name,
"input": parsed,
})
return blocks
def build_trace_jsonl(
messages: List[Dict[str, Any]],
*,
session_id: str,
model: str = "",
cwd: str = "",
redact: bool = True,
) -> str:
"""Render Hermes conversation messages as Claude Code JSONL text.
Each non-system message becomes one JSONL line in the Claude Code
transcript shape the HF Agent Trace Viewer auto-detects:
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
with ``content`` blocks (text + ``tool_use``).
Tool results are emitted as user turns carrying a ``tool_result``
block keyed by ``tool_call_id`` — the same way Claude Code records
them. Turns are linked via ``uuid`` / ``parentUuid``.
"""
lines: List[str] = []
parent: Optional[str] = None
base_ts = _now_iso()
git_branch = ""
try:
import subprocess
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
except Exception:
git_branch = ""
def _common(turn_uuid: str) -> Dict[str, Any]:
return {
"parentUuid": parent,
"isSidechain": False,
"userType": "external",
"cwd": cwd or os.getcwd(),
"sessionId": session_id,
"version": _HERMES_VERSION,
"gitBranch": git_branch,
"uuid": turn_uuid,
"timestamp": base_ts,
}
for msg in messages:
role = msg.get("role")
if role == "system":
continue
turn_uuid = str(uuid.uuid4())
if role == "assistant":
blocks = _content_to_blocks(msg.get("content"), redact)
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
if not blocks:
blocks = [{"type": "text", "text": ""}]
entry = _common(turn_uuid)
entry["type"] = "assistant"
entry["message"] = {
"role": "assistant",
"model": model or "unknown",
"content": blocks,
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
if role == "tool":
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
result_content = _redact(
msg.get("content") if isinstance(msg.get("content"), str)
else json.dumps(msg.get("content")),
redact,
)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content,
}],
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
# Default: user (and any unknown role) -> user turn.
content = msg.get("content")
if isinstance(content, str):
message_content: Any = _redact(content, redact)
else:
message_content = _content_to_blocks(content, redact)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {"role": "user", "content": message_content}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
return "\n".join(lines) + ("\n" if lines else "")
# ---------------------------------------------------------------------------
# Upload
# ---------------------------------------------------------------------------
def _resolve_hf_token() -> Optional[str]:
"""Return the user's Hugging Face token from the usual env vars."""
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
val = os.getenv(var)
if val and val.strip():
return val.strip()
return None
_NO_TOKEN_MESSAGE = (
"Can't upload — no Hugging Face token is available. To set it up:\n"
"\n"
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
" (New token -> type \"Write\" -> copy it).\n"
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
"3. Run /upload-trace again (or `hermes trace upload`)."
)
def _do_upload(
jsonl: str,
*,
token: str,
session_id: str,
dataset_name: str = DEFAULT_DATASET_NAME,
private: bool = True,
) -> str:
"""Create (idempotently) the private dataset and push the trace file.
Returns a user-facing status string. Never raises.
"""
try:
from tools import lazy_deps
lazy_deps.ensure("tool.trace_upload", prompt=False)
except Exception:
# lazy-install unavailable/declined — fall through to the import,
# which surfaces the install hint below if the package is missing.
pass
try:
from huggingface_hub import HfApi
except ImportError:
return ("Hugging Face upload needs the `huggingface_hub` package "
"(`pip install huggingface_hub`).")
api = HfApi(token=token)
try:
who = api.whoami()
user = who.get("name") if isinstance(who, dict) else None
except Exception as e:
logger.warning("HF whoami failed: %s", e)
return ("Your Hugging Face token was rejected (whoami failed). "
"Make sure it has WRITE access and isn't expired.")
if not user:
return "Could not resolve your Hugging Face username from the token."
repo_id = f"{user}/{dataset_name}"
try:
api.create_repo(
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
)
except Exception as e:
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
return f"Could not create/access dataset {repo_id}: {e}"
path_in_repo = f"sessions/{session_id}.jsonl"
try:
api.upload_file(
path_or_fileobj=jsonl.encode("utf-8"),
path_in_repo=path_in_repo,
repo_id=repo_id,
repo_type="dataset",
commit_message=f"add session trace {session_id}",
)
except Exception as e:
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
return f"Upload to Hugging Face failed: {e}"
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
def load_session_messages(
session_id: str, db_path=None
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Load a session's conversation + metadata from the SQLite store.
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
missing (messages may still be present for a live, untitled session).
"""
from hermes_state import SessionDB
db = SessionDB(db_path=db_path) if db_path else SessionDB()
resolved = db.resolve_session_id(session_id) or session_id
meta = db.get_session(resolved) or {}
messages = db.get_messages_as_conversation(resolved)
return messages, meta
def upload_session_trace(
session_id: str,
*,
model: str = "",
cwd: str = "",
redact: bool = True,
private: bool = True,
dataset_name: str = DEFAULT_DATASET_NAME,
db_path=None,
token: Optional[str] = None,
) -> str:
"""Top-level entry point used by the CLI/gateway/subcommand.
Loads the session, converts it to Claude Code JSONL, and uploads it to
the user's private ``{user}/hermes-traces`` dataset. Returns a
user-facing status string and never raises.
"""
if not session_id:
return "No active session to upload."
token = token or _resolve_hf_token()
if not token:
return _NO_TOKEN_MESSAGE
try:
messages, meta = load_session_messages(session_id, db_path=db_path)
except Exception as e:
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
return f"Could not load session {session_id}: {e}"
if not messages:
return "No transcript to upload for this session yet."
resolved_model = model or meta.get("model") or ""
try:
jsonl = build_trace_jsonl(
messages,
session_id=session_id,
model=resolved_model,
cwd=cwd,
redact=redact,
)
except TraceRedactionError:
return _REDACTION_BLOCKED_MESSAGE
if not jsonl.strip():
return "No transcript content to upload for this session."
return _do_upload(
jsonl,
token=token,
session_id=session_id,
dataset_name=dataset_name,
private=private,
)
+19 -6
View File
@@ -4167,17 +4167,15 @@ function installPreviewShortcut(window) {
// survives reloads/restarts) rather than a main-process JSON file. The main
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, -9), 9)
}
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) return
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
// Keep any open settings UI in sync, including changes made via the
// keyboard shortcuts or the View menu.
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@@ -6158,6 +6156,21 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
return { ok: true }
})
// --- Text size (zoom) -------------------------------------------------------
// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd
// shortcuts and the View menu. Reads and writes target the asking window.
ipcMain.handle('hermes:zoom:get', event => {
const window = BrowserWindow.fromWebContents(event.sender)
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0
return { level, percent: zoomLevelToPercent(level) }
})
ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed()) return
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
})
// --- Pet overlay (pop-out mascot) -----------------------------------------
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's
+12
View File
@@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
zoom: {
// Current zoom of this window, as { level, percent }.
get: () => ipcRenderer.invoke('hermes:zoom:get'),
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
// so the settings UI can stay in sync with the keyboard.
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
}
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
+34
View File
@@ -0,0 +1,34 @@
/**
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
* through this one clamped scale. Percent is the user-facing unit (100 = the
* default size); Chromium's internal unit is the zoom level, where
* factor = 1.2 ^ level.
*/
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
const ZOOM_FACTOR_BASE = 1.2
const MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
}
function zoomLevelToPercent(level) {
return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100)
}
function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) return 0
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
module.exports = {
ZOOM_STORAGE_KEY,
clampZoomLevel,
percentToZoomLevel,
zoomLevelToPercent
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Unit tests for the pure zoom helpers: clamping garbage input, the
* percent <-> zoom-level conversion the settings UI relies on, and the
* roundtrip stability of the preset percentages.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
})
test('clampZoomLevel rejects garbage and enforces bounds', () => {
assert.equal(clampZoomLevel(NaN), 0)
assert.equal(clampZoomLevel(Infinity), 0)
assert.equal(clampZoomLevel(undefined), 0)
assert.equal(clampZoomLevel('2'), 0)
assert.equal(clampZoomLevel(0.3), 0.3)
assert.equal(clampZoomLevel(-42), -9)
assert.equal(clampZoomLevel(42), 9)
})
test('level 0 is exactly 100 percent', () => {
assert.equal(zoomLevelToPercent(0), 100)
assert.equal(percentToZoomLevel(100), 0)
})
test('percentToZoomLevel rejects garbage', () => {
assert.equal(percentToZoomLevel(NaN), 0)
assert.equal(percentToZoomLevel(0), 0)
assert.equal(percentToZoomLevel(-50), 0)
assert.equal(percentToZoomLevel(undefined), 0)
})
test('preset percentages roundtrip within rounding', () => {
for (const percent of [90, 100, 110, 125, 150, 175]) {
assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent)
}
})
test('conversion is monotonic across the preset range', () => {
const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)
for (let i = 1; i < levels.length; i++) {
assert.ok(levels[i] > levels[i - 1])
}
})
test('extreme percentages clamp to the level bounds', () => {
assert.equal(percentToZoomLevel(1), -9)
assert.equal(percentToZoomLevel(1_000_000), 9)
})
+1 -1
View File
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+4 -1
View File
@@ -358,7 +358,10 @@ export function ChatView({
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
},
enabled: gatewayOpen
})
@@ -39,6 +39,7 @@ import {
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
import type { RpcEvent } from '@/types/hermes'
@@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
requestDesktopOnboarding(payload.credential_warning)
}
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
@@ -59,7 +59,9 @@ function Harness({
requestGateway,
resumeStoredSession,
seedMessages,
storedSessionId
storedSessionId,
activeSessionId,
createBackendSessionForSend
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
@@ -70,8 +72,12 @@ function Harness({
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
storedSessionId?: null | string
activeSessionId?: null | string
createBackendSessionForSend?: () => Promise<null | string>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const activeSessionIdRef: MutableRefObject<string | null> = {
current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
@@ -87,11 +93,11 @@ function Harness({
} as never)
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef: localBusyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID),
handleSkinCommand: () => '',
openMemoryGraph: openMemoryGraph ?? (() => undefined),
refreshSessions,
@@ -1084,7 +1090,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
@@ -1127,7 +1133,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
@@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => {
// A starved gateway loop rejects with "request timed out: prompt.submit".
// With a stored session selected, that must recover exactly like
// "session not found" — resume + retry — not surface an error that leaves
// activeSessionId null and lets the next send mint a new session.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('request timed out: prompt.submit')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message during starved loop')
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'message during starved loop'
})
})
it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => {
// The exact split path from #55578 symptom (b): the runtime binding is
// gone (orphan-reaped / cleared by a timeout) but a stored session is
// still selected in the sidebar. A follow-up submit must continue that
// conversation via session.resume — createBackendSessionForSend would
// silently fork the user's chat in two.
const calls: { method: string; params?: Record<string, unknown> }[] = []
const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('follow-up in the selected chat')
expect(ok).toBe(true)
expect(createBackendSessionForSend).not.toHaveBeenCalled()
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
})
it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => {
const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID)
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
const ok = await handle!.submitText('first message of a new chat')
expect(ok).toBe(true)
expect(createBackendSessionForSend).toHaveBeenCalledTimes(1)
expect(calls).not.toContain('session.resume')
})
})
describe('usePromptActions eager attachment upload (drop-time)', () => {
@@ -546,7 +546,8 @@ export function usePromptActions({
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
})
const recoveredId = resumed?.session_id
@@ -23,6 +23,7 @@ import {
inlineErrorMessage,
isProviderSetupError,
isSessionBusyError,
isGatewayTimeoutError,
isSessionNotFoundError,
type SubmitTextOptions,
withSessionBusyRetry
@@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId && selectedStoredSessionIdRef.current) {
// A stored session is SELECTED but its runtime binding is gone (the
// live session was orphan-reaped, or a timeout/reconnect cleared
// activeSessionId). Continuing the selected conversation must mean
// resuming it — minting a brand-new backend session here silently
// splits the user's chat in two (#55578 symptom b). Only fall through
// to session creation when NO stored session is selected (a genuine
// new-chat draft).
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
if (resumed?.session_id) {
sessionId = resumed.session_id
activeSessionIdRef.current = sessionId
}
} catch {
// Resume failed (session gone from state.db, gateway hiccup) —
// fall through to creating a fresh session rather than dead-ending
// the user's message.
}
if (sessionId) {
seedOptimistic(sessionId)
}
}
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend(visibleText)
@@ -257,10 +286,18 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
if (
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
selectedStoredSessionIdRef.current
) {
// Re-register the session in the gateway and get a fresh live ID.
// Timeouts recover the same way as "session not found": a starved
// backend loop (#55578 symptom d) rejects the submit even though
// the stored session is fine — resume + retry instead of erroring
// out and losing the session binding.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
})
const recoveredId = resumed?.session_id
@@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean {
return /session not found/i.test(message)
}
// Gateway JSON-RPC calls reject with "request timed out: <method>" when the
// backend event loop is starved (e.g. a poller spin or a heavy async-injected
// turn). For prompt.submit this is indistinguishable from a dead runtime
// session on the client side — recovery must treat it like one (#55578):
// resume the SELECTED stored session and retry, instead of surfacing an error
// that leads to a null activeSessionId and a silently minted new session.
export function isGatewayTimeoutError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /request timed out/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). It's a transient concurrency guard, never a user-facing error: a
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
@@ -148,6 +148,12 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' })
})
it('tags new desktop chats as desktop sessions', async () => {
const params = await createWith(() => {})
expect(params).toMatchObject({ source: 'desktop' })
})
it('passes the current workspace cwd into session.create', async () => {
const params = await createWith(() => {
$currentCwd.set('/remote/worktree')
@@ -328,6 +334,7 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).not.toHaveProperty('lazy')
expect(resumeParams).not.toHaveProperty('eager_build')
expect(resumeParams).toMatchObject({ source: 'desktop' })
})
it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
@@ -412,6 +419,78 @@ describe('resumeSession failure recovery', () => {
})
})
function BranchHarness({
onReady,
requestGateway
}: {
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})
useEffect(() => {
onReady(actions.branchStoredSession)
}, [actions.branchStoredSession, onReady])
return null
}
describe('branchStoredSession desktop source tagging', () => {
afterEach(() => {
cleanup()
setSessions([])
vi.restoreAllMocks()
})
it('tags desktop branch sessions as desktop sessions', async () => {
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(createParams).toMatchObject({
parent_session_id: 'stored-parent',
source: 'desktop'
})
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
// resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached
// state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled
@@ -176,6 +176,7 @@ export function useSessionActions({
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
@@ -460,6 +461,7 @@ export function useSessionActions({
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
source: 'desktop',
// Watch windows attach lazily (live mirror). Every other cold resume
// gets the gateway's default deferred build: the RPC returns the
// transcript immediately instead of blocking the switch on _make_agent
@@ -642,6 +644,7 @@ export function useSessionActions({
// No title: the backend auto-names the branch from its parent's lineage.
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
@@ -19,7 +19,7 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
requestDesktopOnboarding(info.credential_warning)
}
reportInstallMethodWarning(info.install_warning)
if (typeof info.model === 'string') {
setCurrentModel(info.model)
sessionState.model = info.model
@@ -16,6 +16,7 @@ import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedM
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
import { getBaseColors, useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import type { DesktopTheme } from '@/themes/types'
@@ -62,6 +63,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
)
}
// UI scale presets, as zoom percentages. 100 is the browser-default size;
// the ids double as the percent values sent to the main process. A Cmd/Ctrl
// +/- step landing between presets highlights nothing, and the row
// description keeps showing the exact current percent.
const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const
type UiScalePreset = (typeof UI_SCALE_PRESETS)[number]
function matchUiScalePreset(percent: number): UiScalePreset | null {
return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null
}
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
@@ -231,6 +244,7 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const zoomPercent = useStore($zoomPercent)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
@@ -277,6 +291,10 @@ export function AppearanceSettings() {
{ id: 'off', label: a.embedsOff }
] as const satisfies readonly { id: EmbedMode; label: string }[]
const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))
const matchedScalePreset = matchUiScalePreset(zoomPercent)
return (
<SettingsContent>
<div>
@@ -392,6 +410,21 @@ export function AppearanceSettings() {
wide
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setZoomPercent(Number(id))
}}
options={uiScaleOptions}
value={matchedScalePreset ?? ('' as UiScalePreset)}
/>
}
description={a.uiScaleDesc(zoomPercent)}
title={a.uiScaleTitle}
/>
<ListRow
action={
<div className="flex items-center gap-3">
@@ -13,8 +13,10 @@ beforeAll(() => {
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const getMoaModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const saveMoaModels = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
@@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
getMoaModels: () => getMoaModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
@@ -45,15 +49,6 @@ beforeEach(() => {
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{
name: 'DeepSeek',
slug: 'deepseek',
models: [],
authenticated: false,
auth_type: 'api_key',
key_env: 'DEEPSEEK_API_KEY'
}
]
})
@@ -61,8 +56,9 @@ beforeEach(() => {
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
getMoaModels.mockResolvedValue(null)
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
@@ -80,43 +76,19 @@ async function renderModelSettings() {
}
describe('ModelSettings', () => {
it('loads the current main model and lists the full provider universe', async () => {
it('loads the current main model and lists configured providers only', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select — every provider from the full payload should be
// listed, including the unconfigured one with its "set up" hint.
// Open the provider Select — only configured providers should be listed.
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])
// "Nous" shows in both the trigger and the open list; the unconfigured
// provider + its setup hint are the unique signal of the full universe.
// "Nous" shows in both the trigger and the open list.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
expect(await screen.findByText(/set up/)).toBeTruthy()
})
it('activates an unconfigured api_key provider inline by saving its key', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select and pick the unconfigured provider.
const triggers = screen.getAllByRole('combobox')
fireEvent.click(triggers[0])
const deepseekOption = await screen.findByText(/DeepSeek/)
fireEvent.click(deepseekOption)
// The inline key input appears for an api_key provider that needs setup.
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
const activate = await screen.findByRole('button', { name: /Activate/ })
fireEvent.click(activate)
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
expect(screen.queryByText(/DeepSeek/)).toBeNull()
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
@@ -45,7 +45,10 @@ export function ModelVisibilityDialog({
queryKey: ['model-options', sessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId,
explicit_only: true
})
}
return getGlobalModelOptions()
@@ -237,7 +237,7 @@ function ConfirmingModelPanel({
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
const options = useQuery({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
})
const providerRow = options.data?.providers?.find(
@@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
// Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
// tests) is funneled into the same .catch instead of escaping.
void Promise.resolve()
.then(() => getGlobalModelOptions())
.then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }))
.then(res => {
if (!cancelled) {
setRows(res.providers ?? [])
+5
View File
@@ -89,6 +89,11 @@ declare global {
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
zoom?: {
get: () => Promise<{ level: number; percent: number }>
setPercent: (percent: number) => void
onChanged: (callback: (payload: { level: number; percent: number }) => void) => () => void
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
+21 -1
View File
@@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => {
[getHermesConfig, '/api/config'],
[getHermesConfigDefaults, '/api/config/defaults'],
[getGlobalModelInfo, '/api/model/info'],
[() => getGlobalModelOptions(), '/api/model/options'],
[() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'],
[getCronJobs, '/api/cron/jobs']
]
@@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => {
profile: 'xiaoxuxu'
})
})
it('defaults model options to configured providers only', async () => {
await getGlobalModelOptions()
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/model/options?explicit_only=1'
})
)
})
it('can opt into unconfigured providers for onboarding flows', async () => {
await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false })
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/model/options?refresh=1&include_unconfigured=1'
})
)
})
})
+20 -2
View File
@@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
})
}
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
export function getGlobalModelOptions(opts?: {
refresh?: boolean
includeUnconfigured?: boolean
explicitOnly?: boolean
}): Promise<ModelOptionsResponse> {
const params = new URLSearchParams()
if (opts?.refresh) {
params.set('refresh', '1')
}
if (opts?.includeUnconfigured) {
params.set('include_unconfigured', '1')
}
if (opts?.explicitOnly !== false) {
params.set('explicit_only', '1')
}
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options',
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
+4
View File
@@ -119,6 +119,7 @@ export const en: Translations = {
backendOutOfDateTitle: 'Backend out of date',
backendOutOfDateMessage:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
installMethodUnsupportedTitle: 'Unsupported install method',
updateHermes: 'Update Hermes',
updateReadyTitle: 'Update ready',
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
@@ -381,6 +382,9 @@ export const en: Translations = {
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
toolViewTitle: 'Tool Call Display',
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
uiScaleTitle: 'UI Scale',
uiScaleDesc: (percent: number) =>
`Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`,
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
embedsTitle: 'Inline Embeds',
+4
View File
@@ -120,6 +120,7 @@ export const ja = defineLocale({
backendOutOfDateTitle: 'バックエンドが古いです',
backendOutOfDateMessage:
'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。',
installMethodUnsupportedTitle: 'サポート対象外のインストール方法',
updateHermes: 'Hermes を更新',
updateReadyTitle: '更新の準備ができました',
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
@@ -287,6 +288,9 @@ export const ja = defineLocale({
colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
toolViewTitle: 'ツール呼び出しの表示',
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
uiScaleTitle: 'UI スケール',
uiScaleDesc: (percent: number) =>
`アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`,
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
embedsTitle: 'インライン埋め込み',
+3
View File
@@ -161,6 +161,7 @@ export interface Translations {
copyDetailFailed: string
backendOutOfDateTitle: string
backendOutOfDateMessage: string
installMethodUnsupportedTitle: string
updateHermes: string
updateReadyTitle: string
updateReadyMessage: (count: number) => string
@@ -302,6 +303,8 @@ export interface Translations {
colorModeDesc: string
toolViewTitle: string
toolViewDesc: string
uiScaleTitle: string
uiScaleDesc: (percent: number) => string
translucencyTitle: string
translucencyDesc: string
embedsTitle: string
+3
View File
@@ -116,6 +116,7 @@ export const zhHant = defineLocale({
copyDetailFailed: '無法複製通知詳情',
backendOutOfDateTitle: '後端版本過舊',
backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。',
installMethodUnsupportedTitle: '不受支援的安裝方式',
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
@@ -279,6 +280,8 @@ export const zhHant = defineLocale({
colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
toolViewTitle: '工具呼叫顯示',
toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
uiScaleTitle: '介面縮放',
uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`,
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
embedsTitle: '內嵌預覽',
+3
View File
@@ -116,6 +116,7 @@ export const zh: Translations = {
copyDetailFailed: '无法复制通知详情',
backendOutOfDateTitle: '后端版本过旧',
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
installMethodUnsupportedTitle: '不受支持的安装方式',
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
@@ -370,6 +371,8 @@ export const zh: Translations = {
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
toolViewTitle: '工具调用显示',
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
uiScaleTitle: '界面缩放',
uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`,
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
embedsTitle: '内嵌预览',
+1
View File
@@ -51,6 +51,7 @@ export type GatewayEventPayload = {
cwd?: string
branch?: string
credential_warning?: string
install_warning?: string
personality?: string
usage?: Partial<UsageStats>
// agent.terminal.output — live chunk for a read-only agent terminal tab
+10 -1
View File
@@ -1,12 +1,17 @@
import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
interface ModelOptionsRequest {
/** When false, include ambient/unconfigured providers (onboarding/setup
* surfaces). Chat pickers default to true so only explicitly configured
* providers are listed (#56974). */
explicitOnly?: boolean
gateway?: HermesGateway
refresh?: boolean
sessionId?: null | string
}
export function requestModelOptions({
explicitOnly = true,
gateway,
refresh = false,
sessionId
@@ -22,8 +27,12 @@ export function requestModelOptions({
params.refresh = true
}
if (explicitOnly) {
params.explicit_only = true
}
return gateway.request<ModelOptionsResponse>('model.options', params)
}
return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
}
+1 -1
View File
@@ -239,7 +239,7 @@ async function fetchProviderDefaultModel(
let options
try {
options = await getGlobalModelOptions()
options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
} catch {
return null
}
+39
View File
@@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean {
return Number.isFinite(until) && Date.now() < until
}
const INSTALL_METHOD_TOAST_ID = 'install-method-not-supported'
// Same time-based snooze pattern as the update/skew toasts: the warning is
// re-derived from every session.info (session.create/resume/activate all
// route through applyRuntimeInfo), so without a snooze it would re-pop on
// every session switch even right after the user dismissed it.
const INSTALL_METHOD_TOAST_SNOOZE_KEY = 'hermes:install-method-toast-snooze-until'
const INSTALL_METHOD_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeInstallMethodToast(): void {
persistString(INSTALL_METHOD_TOAST_SNOOZE_KEY, String(Date.now() + INSTALL_METHOD_TOAST_COOLDOWN_MS))
}
function isInstallMethodToastSnoozed(): boolean {
const until = Number(storedString(INSTALL_METHOD_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/**
* Guard against a desktop GUI talking to a backend that predates its contract
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
@@ -151,6 +169,27 @@ export function reportBackendContract(contract: number | undefined): void {
})
}
export function reportInstallMethodWarning(message: string | undefined): void {
if (!message) {
dismissNotification(INSTALL_METHOD_TOAST_ID)
return
}
if (isInstallMethodToastSnoozed()) {
return
}
notify({
durationMs: 0,
id: INSTALL_METHOD_TOAST_ID,
kind: 'warning',
message,
onDismiss: () => snoozeInstallMethodToast(),
title: translateNow('notifications.installMethodUnsupportedTitle')
})
}
/**
* Fire a toast when an update is available, at most once per cooldown window.
* Closing the toast — dismissing it or opening the updates window from it —
+22
View File
@@ -0,0 +1,22 @@
/**
* Window text size (zoom).
*
* The main process owns the zoom level and persists it (see electron/zoom.cjs
* for the scale). The renderer only mirrors the current percent for the
* settings UI: preset clicks go to the main process over IPC, and every
* change comes back through onChanged, including ones made with the
* Ctrl/Cmd +/-/0 shortcuts or the View menu, so the UI never drifts.
*/
import { atom } from 'nanostores'
export const $zoomPercent = atom<number>(100)
export function setZoomPercent(percent: number): void {
window.hermesDesktop?.zoom?.setPercent(percent)
}
if (typeof window !== 'undefined' && window.hermesDesktop?.zoom) {
void window.hermesDesktop.zoom.get().then(({ percent }) => $zoomPercent.set(percent))
window.hermesDesktop.zoom.onChanged(({ percent }) => $zoomPercent.set(percent))
}
+1
View File
@@ -399,6 +399,7 @@ export interface SessionRuntimeInfo {
cwd?: string
desktop_contract?: number
fast?: boolean
install_warning?: string
model?: string
personality?: string
provider?: string
+5
View File
@@ -846,6 +846,11 @@ platform_toolsets:
# priority_mode: prepend
# priority:
# - my_plugin_command
# webhook:
# extra:
# # Route scripts default to a 30 second timeout. Scripts must live under
# # the active profile's scripts directory and receive webhook JSON on stdin.
# script_timeout_seconds: 30
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+25 -15
View File
@@ -12745,19 +12745,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not persist active CLI session before close: %s", e)
def _print_exit_summary(self):
"""Print session resume info on exit, similar to Claude Code."""
# Clear the screen + scrollback before printing the summary so the
# live bottom chrome (status bar, input box, separator rules) and the
# rest of the session transcript don't get stranded above the exit
# summary (#38252). By this point app.run() has returned and
# prompt_toolkit has restored terminal modes, so writing raw escapes
# to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the
# visible screen, ESC[H homes the cursor — so the summary prints at a
# clean top-left. Falls back to the platform clear command if stdout
# isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by
# skipping silently when there's no real console.
self._clear_terminal_on_exit()
def _print_exit_summary(self, clear_screen: bool = True):
"""Print session resume info on exit, similar to Claude Code.
Args:
clear_screen: When True (default), clear the terminal screen and
scrollback before printing the summary. This is appropriate for
interactive TUI teardown (#38252). Single-query (-q) mode should
pass False to preserve the printed answer (#53009).
"""
if clear_screen:
# Clear the screen + scrollback before printing the summary so the
# live bottom chrome (status bar, input box, separator rules) and the
# rest of the session transcript don't get stranded above the exit
# summary (#38252). By this point app.run() has returned and
# prompt_toolkit has restored terminal modes, so writing raw escapes
# to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the
# visible screen, ESC[H homes the cursor — so the summary prints at a
# clean top-left. Falls back to the platform clear command if stdout
# isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by
# skipping silently when there's no real console.
self._clear_terminal_on_exit()
print()
msg_count = len(self.conversation_history)
if msg_count > 0:
@@ -15082,7 +15090,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# and watch pattern matches) while agent is idle.
try:
from tools.process_registry import process_registry
for _evt, _synth in process_registry.drain_notifications():
from tools.approval import get_current_session_key
_drain_sk = get_current_session_key(default="")
for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):
self._pending_input.put(_synth)
except Exception:
pass
@@ -16169,7 +16179,7 @@ def main(
# banner, doesn't depend on the welcome banner being shown.
cli._show_security_advisories()
cli.chat(query, images=single_query_images or None)
cli._print_exit_summary()
cli._print_exit_summary(clear_screen=False)
finally:
_finalize_single_query(cli)
return
+58 -3
View File
@@ -84,6 +84,15 @@ TICKER_INTERVAL_SECONDS = 60
# concurrent mark_job_run / advance_next_run calls can clobber each other.
_jobs_file_lock = threading.RLock()
_jobs_lock_state = threading.local()
# Upper bound on waiting for the cross-process .jobs.lock flock (#60703).
# Every cron function in the process funnels through _jobs_lock(), and the
# flock is taken while holding the process-wide RLock — so an unbounded wait
# on a lock held by a wedged sibling process silently freezes the ticker
# heartbeat and every job forever. 30s is orders of magnitude above any
# legitimate critical section (field updates only) while keeping the ticker's
# worst-case stall well under one status-alarm threshold.
_JOBS_LOCK_TIMEOUT_SECONDS = 30.0
OUTPUT_DIR = CRON_DIR / "output"
ONESHOT_GRACE_SECONDS = 120
@@ -177,7 +186,42 @@ def _jobs_lock():
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
lock_fd.seek(0)
if fcntl is not None:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
# Bounded acquisition (#60703): a plain blocking
# fcntl.flock(LOCK_EX) here has NO timeout, and it is
# taken while holding the process-wide _jobs_file_lock
# RLock above. If another process wedges while holding
# .jobs.lock (e.g. an old gateway draining through a
# restart), a single blocked acquirer freezes EVERY cron
# function in this process — including the ticker's
# get_due_jobs() — silently and forever: the heartbeat
# file stops updating and all jobs stop firing with no
# error logged. Poll LOCK_NB against a deadline instead;
# on timeout, log loudly and fall through to the same
# in-process-only degraded mode used when locking is
# unavailable. A briefly-torn cross-process write is
# strictly better than a permanently dead scheduler.
_deadline = time.monotonic() + _JOBS_LOCK_TIMEOUT_SECONDS
while True:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (OSError, IOError):
if time.monotonic() >= _deadline:
logger.error(
"Timed out after %.0fs waiting for the cron "
"jobs lock (%s) — another process is holding "
"it. Proceeding with in-process locking only "
"so the scheduler stays alive (#60703).",
_JOBS_LOCK_TIMEOUT_SECONDS,
_jobs_lock_file(),
)
try:
lock_fd.close()
except OSError:
pass
lock_fd = None
break
time.sleep(0.1)
elif msvcrt is not None:
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
except (OSError, IOError) as e:
@@ -1565,7 +1609,14 @@ def claim_job_for_fire(job_id: str, *, claim_ttl_seconds: int = 300) -> bool:
if existing:
try:
claimed_at = _ensure_aware(datetime.fromisoformat(existing["at"]))
if (now - claimed_at).total_seconds() < claim_ttl_seconds:
# Bounded on BOTH sides (#60703): a claim stamped in the
# future (clock/TZ skew across a restart, or a corrupted
# timestamp) would otherwise have a negative age and stay
# "fresh" forever — the job becomes permanently unfireable
# and every manual `cron run` reports "already being
# fired". Treat future-dated claims as stale/overwritable.
_age = (now - claimed_at).total_seconds()
if 0 <= _age < claim_ttl_seconds:
return False # someone holds a fresh claim
except Exception:
pass # malformed claim → overwrite
@@ -1626,7 +1677,11 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
claimed_at = _ensure_aware(
datetime.fromisoformat(existing_claim["at"])
)
if (now - claimed_at).total_seconds() < _run_claim_ttl:
# 0 <= age: a future-dated claim (clock/TZ skew across a
# restart) must be treated as stale, not eternally fresh,
# or the one-shot is skipped forever (#60703).
_age = (now - claimed_at).total_seconds()
if 0 <= _age < _run_claim_ttl:
continue # a fresh claim is held by an in-flight run
except (KeyError, ValueError, TypeError):
pass # malformed claim → fall through and (re)claim
+114 -2
View File
@@ -298,6 +298,102 @@ _parallel_pool_max_workers: Optional[int] = None
_running_job_ids: set = set()
_running_lock = threading.Lock()
# Job IDs the gateway shutdown path force-killed the tool subprocess of
# while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted``
# below). ``run_one_job``'s own completion path checks this set before
# writing its own ``last_status`` so a cron agent thread that keeps running
# in-process after its tool was killed out from under it — and produces a
# plausible-looking final response from truncated output — can never
# overwrite the interrupted status with a false "ok" (#60432).
_interrupted_job_ids: set = set()
def get_running_job_ids() -> "frozenset[str]":
"""Thread-safe snapshot of cron job IDs currently executing.
A job ID is a member from the moment ``_submit_with_guard`` dispatches
it onto the parallel/sequential pool until ``_process_job`` returns —
i.e. for the job's *entire* run, tool calls included, not just the
ticker's dispatch instant.
The gateway shutdown path (``gateway/run.py::GatewayRunner.
_drain_active_agents``) reads this to treat in-flight cron work as
active the same way it already treats in-flight chat sessions via
``_running_agents`` — cron jobs run through their own thread pool here,
entirely outside that dict, so without this the drain is structurally
blind to them (#60432).
"""
with _running_lock:
return frozenset(_running_job_ids)
def mark_running_jobs_interrupted(reason: str) -> list:
"""Best-effort: mark every currently in-flight cron job interrupted.
Called by the gateway shutdown path immediately after it force-kills
tool subprocesses (``process_registry.kill_all()``). A job whose tool
subprocess was just killed out from under it must never be allowed to
report success — even though its agent thread is still alive in this
same process and may go on to produce a plausible-looking final
response from the now-truncated tool output.
Records the job IDs in ``_interrupted_job_ids`` BEFORE writing
``last_status`` so ``run_one_job``'s own eventual completion for the
same job (racing in its own thread) sees the flag and skips its normal
write instead of clobbering this one — see the check near the end of
``run_one_job``. This does not attempt to correlate the killed
subprocess PID to a specific job ID (the process registry tracks PIDs,
not cron job IDs); any job still dispatched at the moment of a forced
kill is treated as interrupted, matching the coarser precedent already
set by ``GatewayRunner._interrupt_running_agents``, which interrupts
every entry in ``_running_agents`` on a drain timeout without
per-agent correlation either.
Returns the list of job IDs marked, for the caller to log.
"""
with _running_lock:
job_ids = list(_running_job_ids)
_interrupted_job_ids.update(job_ids)
marked = []
for job_id in job_ids:
try:
mark_job_run(job_id, False, reason)
marked.append(job_id)
except Exception as e:
logger.warning("Failed to mark job %s interrupted: %s", job_id, e)
return marked
def _is_interrupted(job_id: str) -> bool:
"""Non-destructive peek at whether the shutdown path has marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
Called by ``run_one_job`` BEFORE it decides what to deliver — a job
whose tool subprocess was killed mid-flight may still produce a
plausible-looking ``final_response`` from the truncated output, and
that must not go out to the user as if it were a normal result.
Unlike ``_consume_interrupted_flag`` below, this does not clear the
flag: the later, authoritative check (right before ``last_status`` is
written) still needs to see it."""
with _running_lock:
return job_id in _interrupted_job_ids
def _consume_interrupted_flag(job_id: str) -> bool:
"""Return True and clear the flag if the shutdown path already marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
Called by ``run_one_job`` right before it would otherwise write its own
``last_status``. Consuming (discarding) rather than just checking keeps
the flag from leaking across a later, unrelated run of the same job ID
(recurring jobs reuse their ID every fire)."""
with _running_lock:
if job_id in _interrupted_job_ids:
_interrupted_job_ids.discard(job_id)
return True
return False
# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
@@ -3332,6 +3428,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
if verbose:
logger.info("Output saved to: %s", output_file)
# If the gateway shutdown killed this job's tool subprocess
# mid-flight (#60432), the agent may still have produced a
# plausible-looking final_response from the truncated output --
# force the failure path so the delivered message is an honest
# "this run was interrupted" summary instead of that response.
# Peek-only: the flag stays set for the authoritative check
# right before mark_job_run below.
if success and _is_interrupted(job["id"]):
success = False
error = (
"Interrupted by gateway shutdown before the run finished "
"(tool subprocess was killed mid-flight)."
)
# Deliver the final response to the origin/target chat.
# If the agent responded with [SILENT], skip delivery (but
# output is already saved above). Failed jobs always deliver.
@@ -3370,12 +3480,14 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
success = False
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
mark_job_run(job["id"], success, error, delivery_error=delivery_error)
if not _consume_interrupted_flag(job["id"]):
mark_job_run(job["id"], success, error, delivery_error=delivery_error)
return True
except Exception as e:
logger.error("Error processing job %s: %s", job['id'], e)
mark_job_run(job["id"], False, str(e))
if not _consume_interrupted_flag(job["id"]):
mark_job_run(job["id"], False, str(e))
return False
+18 -5
View File
@@ -128,21 +128,34 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Platforms that don't support direct channel enumeration get session-based
# discovery automatically. Skip infrastructure entries that aren't messaging
# platforms — everything else falls through to _build_from_sessions().
# discovery automatically, but only for platforms connected in THIS gateway
# process. Historical session origins for disabled/decommissioned platforms
# must not be resurrected into the active send-target directory (stale
# targets make send_message route to platforms that can no longer deliver).
_SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"})
adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters}
for plat in Platform:
plat_name = plat.value
if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms:
if (
plat_name in _SKIP_SESSION_DISCOVERY
or plat_name in platforms
or plat_name not in adapter_platform_names
):
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them).
# Platform.__members__, so the loop above misses them). Same
# connected-only rule: don't expose stale session targets for plugins
# that are not loaded.
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
if (
entry.name not in _SKIP_SESSION_DISCOVERY
and entry.name not in platforms
and entry.name in adapter_platform_names
):
platforms[entry.name] = _build_from_sessions(entry.name)
except Exception:
pass
+49
View File
@@ -37,6 +37,43 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
return is_truthy_value(value, default=default)
# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator
# override. Anything not in either set — and a blank/whitespace value — is
# treated as "unset" so it falls through to config.yaml rather than silently
# forcing the flag off.
_MULTIPLEX_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
_MULTIPLEX_FALSY_STRINGS = frozenset({"0", "false", "no", "off"})
def _env_multiplex_profiles_override() -> "bool | None":
"""Resolve the GATEWAY_MULTIPLEX_PROFILES operator override.
Returns ``True``/``False`` when the env var is set to a recognized truthy/
falsy token, or ``None`` when it is unset, blank, or unrecognized — in which
case the caller keeps the config.yaml value (env > config > default). Blank
is deliberately ``None``, not ``False``: a provisioned-but-unpopulated Fly
secret arrives as ``""`` and must NOT shadow a config.yaml opt-in.
"""
raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES")
if raw is None:
return None
token = raw.strip().lower()
if not token:
return None
if token in _MULTIPLEX_TRUTHY_STRINGS:
return True
if token in _MULTIPLEX_FALSY_STRINGS:
return False
logger.warning(
"Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r "
"(expected one of %s or %s); falling back to config.yaml.",
raw,
sorted(_MULTIPLEX_TRUTHY_STRINGS),
sorted(_MULTIPLEX_FALSY_STRINGS),
)
return None
def _coerce_float(value: Any, default: float) -> float:
"""Coerce numeric config values, falling back on malformed input."""
if value is None:
@@ -837,6 +874,18 @@ class GatewayConfig:
# Also honor gateway.multiplex_profiles written by
# ``hermes config set gateway.multiplex_profiles true``.
multiplex_profiles = nested_gateway.get("multiplex_profiles")
# Operator override: GATEWAY_MULTIPLEX_PROFILES wins over config.yaml when
# set to a recognized value. Hosted deployments (Nous Portal / Fly) stamp
# it on the container so the single multiplexed gateway — which the
# connector now depends on for per-profile relay routing — is forced on at
# every boot regardless of the image's config.yaml, while self-hosted
# users keep setting gateway.multiplex_profiles in config.yaml. A blank or
# unrecognized env value falls through to config (the empty-secret trap:
# a provisioned-but-unpopulated Fly secret must not shadow config), so
# this is a genuine 3-tier chain: env > config.yaml > default False.
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is not None:
multiplex_profiles = env_multiplex
if "max_concurrent_sessions" in data:
max_concurrent_raw = data.get("max_concurrent_sessions")
max_concurrent_key = "max_concurrent_sessions"
+52 -1
View File
@@ -58,6 +58,10 @@ from gateway.platforms.base import (
MessageType,
SendResult,
)
from gateway.platforms.webhook_filters import (
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
WebhookRouteProcessor,
)
logger = logging.getLogger(__name__)
@@ -78,7 +82,6 @@ DEFAULT_PORT = 8644
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
_RATE_WINDOW_SECONDS = 60.0
# Hostnames/IP literals that only serve connections originating on the same
# machine. Anything else is treated as a public bind for safety-rail purposes.
_LOOPBACK_HOSTS = frozenset({
@@ -155,6 +158,15 @@ class WebhookAdapter(BasePlatformAdapter):
self._max_body_bytes: int = int(
config.extra.get("max_body_bytes", 1_048_576)
) # 1MB
self._script_timeout_seconds: int = int(
config.extra.get(
"script_timeout_seconds",
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
)
)
self._route_processor = WebhookRouteProcessor(
script_timeout_seconds=self._script_timeout_seconds
)
# ------------------------------------------------------------------
# Lifecycle
@@ -571,6 +583,45 @@ class WebhookAdapter(BasePlatformAdapter):
{"status": "ignored", "event": event_type}
)
if not self._route_processor.route_filters_match(
route_config, payload, event_type, request.headers
):
logger.info(
"[webhook] filtered event=%s route=%s",
event_type,
route_name,
)
return web.json_response(
{
"status": "ignored",
"reason": "filter",
"route": route_name,
}
)
if route_config.get("script"):
# run_route_script shells out (subprocess.run, up to its timeout);
# run it in a worker thread so it can't block the gateway event loop.
keep, transformed_payload = await asyncio.to_thread(
self._route_processor.run_route_script,
route_config.get("script"),
payload,
)
if not keep:
logger.info(
"[webhook] script ignored event=%s route=%s",
event_type,
route_name,
)
return web.json_response(
{
"status": "ignored",
"reason": "script",
"route": route_name,
}
)
payload = transformed_payload or payload
# Format prompt from template
prompt_template = route_config.get("prompt", "")
prompt = self._render_prompt(
+302
View File
@@ -0,0 +1,302 @@
"""Route-local filters and script transforms for the webhook adapter."""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30
_MISSING = object()
def _stringify_filter_value(value: Any) -> str:
if value is _MISSING:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)
return str(value)
def _resolve_profile_path(path_value: Any) -> Optional[Path]:
"""Resolve a user path, mapping ~/.hermes to the active profile home."""
if not isinstance(path_value, str):
return None
raw = os.path.expandvars(path_value.strip())
if not raw:
return None
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
if raw == "~/.hermes":
return hermes_home
if raw.startswith("~/.hermes/"):
return hermes_home / raw.removeprefix("~/.hermes/")
path = Path(raw).expanduser()
if path.is_absolute():
return path
return hermes_home / path
def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:
"""Resolve a route script under HERMES_HOME/scripts."""
if not isinstance(script_value, str) or not script_value.strip():
return None, "script path is empty"
from hermes_constants import get_hermes_home
scripts_root = (get_hermes_home() / "scripts").resolve()
raw_text = os.path.expandvars(script_value.strip())
if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"):
mapped = _resolve_profile_path(raw_text)
candidate = mapped.resolve() if mapped is not None else scripts_root
else:
raw = Path(raw_text).expanduser()
candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()
try:
candidate.relative_to(scripts_root)
except ValueError:
return None, f"script path resolves outside {scripts_root}"
if not candidate.exists():
return None, f"script not found: {candidate}"
if not candidate.is_file():
return None, f"script path is not a file: {candidate}"
return candidate, None
def _load_filter_file_values(path_value: Any) -> list[Any]:
path = _resolve_profile_path(path_value)
if path is None:
return []
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc)
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return [line.strip() for line in raw.splitlines() if line.strip()]
if isinstance(data, list):
return data
if isinstance(data, dict):
return list(data.keys())
return [data]
class WebhookRouteProcessor:
"""Evaluate declarative filters and optional script transforms."""
def __init__(
self,
*,
script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,
) -> None:
self.script_timeout_seconds = max(1, int(script_timeout_seconds))
def resolve_filter_field(
self,
field: Any,
payload: dict,
event_type: str,
headers: Any,
) -> Any:
"""Resolve a dotted filter field against payload/event/headers context."""
if not isinstance(field, str) or not field.strip():
return _MISSING
parts = [part for part in field.strip().split(".") if part]
if not parts:
return _MISSING
header_dict = dict(headers or {})
context = {
"payload": payload.get("payload", payload),
"event": event_type,
"event_type": event_type,
"headers": header_dict,
}
if parts[0] in context:
value: Any = context[parts[0]]
parts = parts[1:]
else:
value = payload
for part in parts:
if isinstance(value, dict):
value = value.get(part, _MISSING)
elif isinstance(value, list) and part.isdigit():
idx = int(part)
value = value[idx] if 0 <= idx < len(value) else _MISSING
else:
return _MISSING
if value is _MISSING:
return _MISSING
return value
def filter_matches(
self,
spec: Any,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
"""Evaluate one declarative webhook filter spec."""
if not isinstance(spec, dict):
logger.warning("[webhook] Ignoring invalid filter spec: %r", spec)
return False
if "all" in spec:
items = spec.get("all")
return isinstance(items, list) and all(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "any" in spec:
items = spec.get("any")
return isinstance(items, list) and any(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "not" in spec:
return not self.filter_matches(spec.get("not"), payload, event_type, headers)
value = self.resolve_filter_field(
spec.get("field"), payload, event_type, headers
)
if "exists" in spec:
exists = value is not _MISSING
return exists is bool(spec.get("exists"))
if spec.get("missing") is True:
return value is _MISSING
if "equals" in spec:
return value is not _MISSING and value == spec.get("equals")
if "not_equals" in spec:
return value is _MISSING or value != spec.get("not_equals")
if "contains" in spec:
needle = spec.get("contains")
if value is _MISSING:
return False
if isinstance(value, (list, tuple, set, dict)):
return needle in value
return str(needle) in _stringify_filter_value(value)
if "in" in spec:
haystack = spec.get("in")
return isinstance(haystack, list) and value in haystack
if "in_file" in spec:
return value in _load_filter_file_values(spec.get("in_file"))
if "regex" in spec:
if value is _MISSING:
return False
try:
return (
re.search(str(spec.get("regex")), _stringify_filter_value(value))
is not None
)
except re.error as exc:
logger.warning("[webhook] Invalid webhook filter regex: %s", exc)
return False
logger.warning("[webhook] Filter spec has no supported operator: %r", spec)
return False
def route_filters_match(
self,
route_config: dict,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
filters = route_config.get("filters") or []
if not filters:
return True
if isinstance(filters, dict):
return self.filter_matches(filters, payload, event_type, headers)
if not isinstance(filters, list):
logger.warning("[webhook] filters must be a list or object")
return False
return all(
self.filter_matches(spec, payload, event_type, headers)
for spec in filters
)
def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:
"""Run a route script and return (should_continue, transformed_payload)."""
path, error = _resolve_script_path(script_value)
if error or path is None:
logger.warning("[webhook] script ignored webhook: %s", error)
return False, None
suffix = path.suffix.lower()
if suffix in {".sh", ".bash"}:
bash = shutil.which("bash") or (
"/bin/bash" if os.path.isfile("/bin/bash") else None
)
if bash is None:
logger.warning("[webhook] script ignored webhook: bash not found")
return False, None
argv = [bash, str(path)]
else:
argv = [sys.executable, str(path)]
try:
from tools.environments.local import _sanitize_subprocess_env
popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
result = subprocess.run(
argv,
input=json.dumps(payload),
capture_output=True,
text=True,
timeout=self.script_timeout_seconds,
cwd=str(path.parent),
env=_sanitize_subprocess_env(os.environ.copy()),
**popen_kwargs,
)
except subprocess.TimeoutExpired:
logger.warning("[webhook] script timed out: %s", path)
return False, None
except Exception as exc:
logger.warning("[webhook] script execution failed: %s", exc)
return False, None
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception as exc:
logger.warning("[webhook] Failed to redact script output: %s", exc)
stdout = "[REDACTED - redaction failed]"
stderr = "[REDACTED - redaction failed]"
if result.returncode != 0:
logger.info(
"[webhook] script ignored webhook path=%s code=%s stderr=%s",
path.name,
result.returncode,
stderr[:200],
)
return False, None
if not stdout or stdout == "[SILENT]":
return False, None
try:
transformed = json.loads(stdout)
except json.JSONDecodeError:
transformed = {**payload, "script_output": stdout}
if not isinstance(transformed, dict):
logger.warning("[webhook] script stdout must be a JSON object or text")
return False, None
if (
transformed.get("[SILENT]") is True
or transformed.get("__hermes_ignore__") is True
):
return False, None
return True, transformed
+147 -37
View File
@@ -179,6 +179,16 @@ OBSERVED_MEDIA_BACKFILL_LOOKBACK = 50
# Max number of resource references to resolve per inbound turn
OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN = 12
# Bounded concurrency for inbound media resolve/download.
# - 1 = sequential (legacy behavior, safe rollback knob)
# - 6 = default; aligns with the per-origin HTTP/1.1 ceiling browsers use,
# balances first-token latency vs. backend pressure
# - 12 = upper clamp; matches OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN
# Configured via config.yaml: platforms.yuanbao.extra.media_resolve_concurrency.
_DEFAULT_RESOLVE_CONCURRENCY = 6
_MIN_RESOLVE_CONCURRENCY = 1
_MAX_RESOLVE_CONCURRENCY = 12
class MarkdownProcessor:
"""Encapsulates all Markdown-related utilities for the Yuanbao platform.
@@ -2813,45 +2823,87 @@ class MediaResolveMiddleware(InboundMiddleware):
Yuanbao COS hostnames resolve to private IPs, tripping the SSRF guard
in vision_tools. We download ourselves and return local cache paths.
Resolution runs with bounded concurrency
(``adapter.media_resolve_concurrency``); see :meth:`_resolve_ybres_refs`
for the same order-preserving / exception-isolated contract.
"""
# Pre-filter resolvable refs, preserving input order.
media_urls: List[str] = []
media_types: List[str] = []
active: List[Tuple[str, str, str, str]] = []
for ref in media_refs:
kind = str(ref.get("kind") or "").strip().lower()
url = str(ref.get("url") or "").strip()
filename = str(ref.get("name") or "").strip()
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
continue
# Extract resourceId from the placeholder URL for cache dedup.
rid = ExtractContentMiddleware._parse_resource_id(url)
if rid and cls._append_cached_resource(adapter, rid, media_urls, media_types):
continue
active.append((kind, url, filename, rid or ""))
try:
fetch_url = await cls._resolve_download_url(adapter, url)
except Exception as exc:
logger.warning(
"[%s] inbound media resolve failed: kind=%s url=%s err=%s",
adapter.name, kind, url, exc,
if not active:
return media_urls, media_types
semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency)
async def _resolve_one(
kind: str, url: str, filename: str, rid: str,
) -> Optional[Tuple[str, str]]:
async with semaphore:
try:
fetch_url = await cls._resolve_download_url(adapter, url)
except Exception as exc:
logger.warning(
"[%s] inbound media resolve failed: kind=%s url=%s err=%s",
adapter.name, kind, url, exc,
)
return None
return await cls._download_and_cache(
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=filename or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=filename or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
if cached is None:
_t0 = time.monotonic()
results = await asyncio.gather(
*(_resolve_one(kind, url, filename, rid)
for kind, url, filename, rid in active),
return_exceptions=True,
)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
_failed = 0
for (kind, url, _filename, _rid), result in zip(active, results):
if isinstance(result, BaseException):
logger.warning(
"[%s] inbound media resolve crashed: kind=%s url=%s err=%s",
adapter.name, kind, url[:80], result,
)
_failed += 1
continue
local_path, mime = cached
if result is None:
_failed += 1
continue
local_path, mime = result
media_urls.append(local_path)
media_types.append(mime)
# Batch summary: keep fields stable for offline aggregation
# (concurrency vs elapsed_ms is the core knob-tuning view).
logger.info(
"[%s] media resolve batch: scope=media concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d",
adapter.name,
adapter.media_resolve_concurrency,
len(active),
len(media_urls),
_failed,
_elapsed_ms,
)
return media_urls, media_types
@classmethod
@@ -2862,36 +2914,81 @@ class MediaResolveMiddleware(InboundMiddleware):
*,
log_prefix: str,
) -> Tuple[List[str], List[str]]:
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
"""Resolve ``(rid, kind, filename)`` ybres tuples to local paths.
Runs with bounded concurrency (``adapter.media_resolve_concurrency``)
so cold-start turns with many anchors don't pay ``N × (RPC + download)``
sequentially. Output order matches input; per-rid failures are isolated.
"""
# Pre-filter resolvable kinds, preserving input order.
# Cache-hit refs are served immediately and excluded from the gather.
media_paths: List[str] = []
mimes: List[str] = []
active: List[Tuple[str, str, str]] = []
for rid, kind, filename in refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
if cls._append_cached_resource(adapter, rid, media_paths, mimes):
continue
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
active.append((rid, kind, filename))
if not active:
return media_paths, mimes
semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency)
async def _resolve_one(rid: str, kind: str, filename: str) -> Optional[Tuple[str, str]]:
async with semaphore:
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
)
return None
return await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
# return_exceptions=True isolates per-coroutine failures.
_t0 = time.monotonic()
results = await asyncio.gather(
*(_resolve_one(rid, kind, filename) for rid, kind, filename in active),
return_exceptions=True,
)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
_failed = 0
for (rid, kind, filename), result in zip(active, results):
if isinstance(result, BaseException):
logger.warning(
"[%s] %s resolve crashed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, result,
)
_failed += 1
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
if cached is None:
if result is None:
_failed += 1
continue
path, mime = cached
path, mime = result
media_paths.append(path)
mimes.append(mime)
# Batch summary: stable fields for offline aggregation.
logger.info(
"[%s] media resolve batch: scope=ybres concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d",
adapter.name,
adapter.media_resolve_concurrency,
len(active),
len(media_paths),
_failed,
_elapsed_ms,
)
return media_paths, mimes
@classmethod
@@ -5071,6 +5168,19 @@ class YuanbaoAdapter(BasePlatformAdapter):
self._api_domain: str = (_extra.get("api_domain") or DEFAULT_API_DOMAIN).rstrip("/")
self._route_env: str = (_extra.get("route_env") or "").strip()
# Bounded concurrency for inbound media resolve/download.
# See _DEFAULT_RESOLVE_CONCURRENCY for rationale; clamped to [min, max]
# so a misconfigured value cannot hammer the resource backend nor
# accidentally drop below sequential behavior.
try:
_raw_concurrency = int(_extra.get("media_resolve_concurrency", _DEFAULT_RESOLVE_CONCURRENCY))
except (TypeError, ValueError):
_raw_concurrency = _DEFAULT_RESOLVE_CONCURRENCY
self.media_resolve_concurrency: int = max(
_MIN_RESOLVE_CONCURRENCY,
min(_MAX_RESOLVE_CONCURRENCY, _raw_concurrency),
)
# Core managers (UML composition)
self._connection: ConnectionManager = ConnectionManager(self)
self._outbound: OutboundManager = OutboundManager(self)
+78 -6
View File
@@ -436,6 +436,80 @@ def _post_provision(
return payload
def _resolve_relay_identity_token() -> str:
"""Resolve the caller-identity bearer token the connector introspects to a tenant.
Canonical resolver shared by the runtime self-provision path and the
``hermes gateway enroll`` CLI. Two modes, in precedence order:
1. **Generic OIDC client-credentials** (air-gapped / self-hosted-IdP, NO
Nous Portal): when ``gateway.idp.token_url`` (or
``GATEWAY_RELAY_IDP_TOKEN_URL``) is configured, obtain a workload access
token via the OAuth2 ``client_credentials`` grant against the operator's
own IdP (Entra; Authentik in the sandbox). The connector's Seam-A OIDC
verifier reads a claim (default ``tid``) off it as the tenant.
2. **Nous Portal** (default): ``resolve_nous_access_token()`` — existing
managed/hosted behaviour.
Raises on failure; callers decide whether that's fatal (enroll CLI) or a
graceful boot no-op (self-provision).
"""
token_url = os.environ.get("GATEWAY_RELAY_IDP_TOKEN_URL", "").strip()
client_id = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_ID", "").strip()
client_secret = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_SECRET", "").strip()
scope = os.environ.get("GATEWAY_RELAY_IDP_SCOPE", "").strip()
if not token_url:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
idp = ((_load_gateway_config().get("gateway") or {}).get("idp") or {})
token_url = str(idp.get("token_url", "") or "").strip()
client_id = client_id or str(idp.get("client_id", "") or "").strip()
client_secret = client_secret or str(idp.get("client_secret", "") or "").strip()
scope = scope or str(idp.get("scope", "") or "").strip()
except Exception: # noqa: BLE001 - config absence must not crash
token_url = token_url or ""
if not token_url:
# Mode 2 — Nous Portal (default, unchanged behaviour).
from hermes_cli.auth import resolve_nous_access_token
return resolve_nous_access_token()
# Mode 1 — generic OAuth2 client_credentials grant.
import json
import urllib.error
import urllib.parse
import urllib.request
if not client_id or not client_secret:
raise RuntimeError(
"gateway.idp.token_url configured but client_id/client_secret missing"
)
form = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}
if scope:
form["scope"] = scope
req = urllib.request.Request(
token_url,
data=urllib.parse.urlencode(form).encode("utf-8"),
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=15.0) as resp:
payload = json.loads(resp.read().decode())
access_token = (payload or {}).get("access_token")
if not isinstance(access_token, str) or not access_token.strip():
raise RuntimeError("IdP client_credentials response had no access_token")
return access_token.strip()
def self_provision_relay() -> bool:
"""Boot-time relay self-provision: mint relay creds in-process, no human, no disk.
@@ -489,13 +563,11 @@ def self_provision_relay() -> bool:
return False
try:
from hermes_cli.auth import resolve_nous_access_token
access_token = resolve_nous_access_token()
access_token = _resolve_relay_identity_token()
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
# No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled)
# -> nothing to provision with; skip quietly and let the gateway boot.
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
# No resolvable identity (e.g. a self-hosted box that hasn't enrolled and
# configured no IdP) -> nothing to provision with; skip quietly and boot.
logger.warning("relay self-provision skipped: could not resolve identity token (%s)", exc)
return False
identities = relay_platform_identities()
+9
View File
@@ -120,6 +120,15 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
scope_id=src.get("scope_id"),
parent_chat_id=src.get("parent_chat_id"),
message_id=src.get("message_id"),
# The HERMES profile this event is routed to (multiplex mode). The
# connector stamps it on the wire source when NAS resolves the target
# profile for a Team-Gateway message; absent for a single-profile
# gateway, where it stays None and session keys keep the legacy
# ``agent:main`` namespace (SessionStore._resolve_profile_for_key).
# Consumed by build_session_key's profile namespacing + the per-turn
# config/credential scope — the same field the /p/<profile>/ HTTP
# prefix and per-credential polling adapters already set.
profile=src.get("profile"),
# Authentic upstream-trust signal: this event arrived over the
# per-instance-authenticated relay WS, so the connector already resolved
# it to this instance's owner-bound author. ``platform`` is the
+128 -12
View File
@@ -4080,6 +4080,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
def _running_agent_count(self) -> int:
return len(self._running_agents)
def _active_cron_job_count(self) -> int:
"""Count of cron jobs currently executing, from the cron scheduler's
own in-flight tracking (``cron.scheduler._running_job_ids``).
Cron jobs run through a standalone ``AIAgent`` on the scheduler's own
thread pool (``cron/scheduler.py::run_job``), entirely outside
``self._running_agents`` — the dict every OTHER active-work check on
this class (``_running_agent_count``, ``_drain_active_agents``) reads.
Without this, the shutdown drain is structurally blind to in-flight
cron work: it can report ``active_at_start=0`` and proceed straight
to killing tool subprocesses while a cron job's terminal command is
still running (#60432). Best-effort: returns 0 if the cron module
can't be imported (e.g. a minimal test double for this class).
"""
try:
from cron.scheduler import get_running_job_ids
return len(get_running_job_ids())
except Exception:
return 0
# ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ──────────────
# The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives
# (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the
@@ -5550,18 +5570,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]:
snapshot = self._snapshot_running_agents()
last_active_count = self._running_agent_count()
last_cron_count = self._active_cron_job_count()
last_status_at = 0.0
def _maybe_update_status(force: bool = False) -> None:
nonlocal last_active_count, last_status_at
nonlocal last_active_count, last_cron_count, last_status_at
now = asyncio.get_running_loop().time()
active_count = self._running_agent_count()
if force or active_count != last_active_count or (now - last_status_at) >= 1.0:
cron_count = self._active_cron_job_count()
if (
force
or active_count != last_active_count
or cron_count != last_cron_count
or (now - last_status_at) >= 1.0
):
self._update_runtime_status("draining")
last_active_count = active_count
last_cron_count = cron_count
last_status_at = now
if not self._running_agents:
# Cron jobs run on the scheduler's own thread pool, outside
# ``self._running_agents`` — fold their in-flight count into the
# same wait/timeout this method already applies to chat sessions,
# or a cron job's tool work gets killed with zero warning the
# instant it's the only active thing running (#60432).
if not self._running_agents and last_cron_count == 0:
_maybe_update_status(force=True)
return snapshot, False
@@ -5570,10 +5603,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return snapshot, True
deadline = asyncio.get_running_loop().time() + timeout
while self._running_agents and asyncio.get_running_loop().time() < deadline:
while (
(self._running_agents or self._active_cron_job_count())
and asyncio.get_running_loop().time() < deadline
):
_maybe_update_status()
await asyncio.sleep(0.1)
timed_out = bool(self._running_agents)
timed_out = bool(self._running_agents) or bool(self._active_cron_job_count())
_maybe_update_status(force=True)
return snapshot, timed_out
@@ -7944,6 +7980,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
except Exception as _e:
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
try:
# Any cron job still dispatched at this instant just had
# its tool subprocess killed above (kill_all() has no
# per-job-ID targeting — it's a global sweep). Its agent
# thread is still alive in this process and may go on to
# produce a plausible-looking final response from the
# now-truncated tool output; mark the run interrupted so
# the scheduler can never report that as success (#60432).
# No-op when no cron job is in flight.
from cron.scheduler import mark_running_jobs_interrupted
_interrupted = mark_running_jobs_interrupted(
f"Gateway shutdown ({phase}) killed the job's tool "
"subprocess before the run finished."
)
if _interrupted:
logger.warning(
"Shutdown (%s): marked %d in-flight cron job(s) interrupted: %s",
phase, len(_interrupted), ", ".join(_interrupted),
)
except Exception as _e:
logger.debug("mark_running_jobs_interrupted (%s) error: %s", phase, _e)
try:
from tools.async_delegation import interrupt_all as _interrupt_async
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
@@ -8004,16 +8061,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as _e:
logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e)
_cron_at_start = self._active_cron_job_count()
_drain_started_at = time.monotonic()
active_agents, timed_out = await self._drain_active_agents(timeout)
logger.info(
"Shutdown phase: drain done at +%.2fs (drain took %.2fs, "
"timed_out=%s, active_at_start=%d, active_now=%d)",
"timed_out=%s, active_at_start=%d, active_now=%d, "
"cron_at_start=%d, cron_now=%d)",
_phase_elapsed(),
time.monotonic() - _drain_started_at,
timed_out,
len(active_agents),
self._running_agent_count(),
_cron_at_start,
self._active_cron_job_count(),
)
if not timed_out:
@@ -8032,9 +8093,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if timed_out:
logger.warning(
"Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.",
"Gateway drain timed out after %.1fs with %d active agent(s) "
"and %d in-flight cron job(s); interrupting remaining work.",
timeout,
self._running_agent_count(),
self._active_cron_job_count(),
)
# Mark forcibly-interrupted sessions as resume_pending BEFORE
# interrupting the agents. This preserves each session's
@@ -10570,6 +10633,46 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
session_entry = self.session_store.get_or_create_session(source)
session_key = session_entry.session_key
pinned_session_id = str(
(getattr(event, "metadata", None) or {}).get("gateway_session_id") or ""
).strip()
if pinned_session_id and pinned_session_id != session_entry.session_id:
# Fail closed (#55578): the spawning session may have ENDED since
# dispatch (user /new-reset, compression rotation whose parent was
# closed). switch_session() re-opens ended sessions, so pinning
# blindly would RESURRECT a conversation the user explicitly
# ended and inject into it — the same illicit-revival class as
# the ws_orphan_reap loop (#60609). A completion whose spawning
# session is dead is dropped from injection; the subagent's
# output remains in the delegation records.
pinned_row = None
try:
if self._session_db is not None:
# AsyncSessionDB already offloads to a thread.
pinned_row = await self._session_db.get_session(pinned_session_id)
except Exception:
pinned_row = None
if pinned_row is None or pinned_row.get("ended_at"):
logger.warning(
"Async-delegation completion pinned to session %s, which is "
"%s — dropping injection instead of resurrecting it "
"(#55578 fail-closed; result remains in the delegation "
"records).",
pinned_session_id,
"unknown" if pinned_row is None else "ended",
)
return
prior_session_id = session_entry.session_id
switched = self.session_store.switch_session(session_key, pinned_session_id)
if switched is not None:
session_entry = switched
logger.info(
"Pinned async-delegation completion to spawning session %s "
"(was %s) for routing key %s (#57498)",
pinned_session_id,
prior_session_id,
session_key,
)
self._cache_session_source(session_key, source)
if await asyncio.to_thread(self._is_telegram_topic_lane, source):
try:
@@ -15080,6 +15183,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower()
chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip()
if not platform_name or not chat_type or not chat_id:
logger.warning(
"Synthetic event source unresolvable: "
"session_key=%r platform=%r chat_type=%r chat_id=%r "
"evt_type=%s",
session_key, platform_name, chat_type, chat_id,
evt.get("type", "?"),
)
return None
try:
@@ -15132,12 +15242,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if not adapter:
return
try:
metadata = {}
parent_session_id = str(evt.get("parent_session_id") or "").strip()
if parent_session_id:
metadata["gateway_session_id"] = parent_session_id
synth_event = MessageEvent(
text=synth_text,
message_type=MessageType.TEXT,
source=source,
internal=True,
message_id=str(evt.get("message_id") or "").strip() or None,
metadata=metadata,
)
logger.info(
"Watch pattern notification — injecting for %s chat=%s thread=%s",
@@ -18240,11 +18355,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"conversation context (possible FTS write corruption)",
session_key, len(agent_history), len(_selected),
)
# The live in-memory history bypassed the replay-cleanup
# pass inside _build_gateway_agent_history — re-apply the
# stale-confirmation expiry (#59607) so a dangerous
# confirmation can't slip through this path either.
# Idempotent; messages without timestamps are untouched.
# The live in-memory history bypassed the
# _build_gateway_agent_history cleanup pipeline above —
# re-apply the stale-confirmation expiry (#59607) so a
# dangerous confirmation can't slip through this path
# either. Idempotent; messages without timestamps are
# untouched.
agent_history = _strip_stale_dangerous_confirmations(
_selected, now=time.time()
)
+11
View File
@@ -79,6 +79,13 @@ _SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNS
_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
# In-process UI session/window id for multi-session desktop/TUI hosts. This is
# intentionally separate from HERMES_SESSION_ID: the latter is the durable
# conversation/session-db id, while the UI id is the live frontend tab/window
# that commissioned a detached completion. Background completions use it as a
# precise return address so a stale/rotated durable session key cannot be
# consumed by whichever desktop poller wakes first.
_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET)
# ID of the message that triggered the current turn. Used as a reply anchor
# so background-process notifications stay inside the originating Telegram
# private-chat topic (those lanes route only with thread id + reply anchor).
@@ -123,6 +130,7 @@ _VAR_MAP = {
"HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
"HERMES_SESSION_KEY": _SESSION_KEY,
"HERMES_SESSION_ID": _SESSION_ID,
"HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID,
"HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
"HERMES_SESSION_PROFILE": _SESSION_PROFILE,
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
@@ -160,6 +168,7 @@ def set_session_vars(
profile: str = "",
cwd: str = "",
async_delivery: bool = True,
ui_session_id: str = "",
) -> list:
"""Set all session context variables and return reset tokens.
@@ -191,6 +200,7 @@ def set_session_vars(
_SESSION_USER_NAME.set(user_name),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_UI_SESSION_ID.set(ui_session_id),
_SESSION_MESSAGE_ID.set(message_id),
_SESSION_PROFILE.set(profile),
_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
@@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_USER_NAME,
_SESSION_KEY,
_SESSION_ID,
_SESSION_UI_SESSION_ID,
_SESSION_MESSAGE_ID,
_SESSION_PROFILE,
):
+18
View File
@@ -167,6 +167,24 @@ class GatewaySlashCommandsMixin:
if _qe is not None:
_qe.pop(session_key, None)
# The old conversation's in-flight async delegations end WITH it
# (#55578): after the reset rotates the session id, their completions
# would have no live owner — a dangling subagent can only burn tokens
# and park an orphaned payload on the shared queue. Interrupt by the
# expiring durable session id (delegations dispatched from gateway
# chats are pinned to it via parent_session_id) and by the routing
# key as a fallback for older records.
try:
from tools.async_delegation import interrupt_for_session
interrupt_for_session(
session_key=session_key,
parent_session_id=str(getattr(old_entry, "session_id", "") or ""),
reason="session_reset",
)
except Exception:
pass
try:
from tools.env_passthrough import clear_env_passthrough
clear_env_passthrough()
+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.18.0"
__release_date__ = "2026.7.1"
__version__ = "0.18.2"
__release_date__ = "2026.7.7.2"
def _ensure_utf8():
+44 -2
View File
@@ -701,14 +701,29 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->
if detected and detected.get("base_url"):
# Persist the detection result keyed on the API key hash.
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
state["detected_endpoint"] = {
detected_endpoint = {
"base_url": detected["base_url"],
"endpoint_id": detected.get("id", ""),
"model": detected.get("model", ""),
"label": detected.get("label", ""),
"key_hash": key_hash,
}
_save_provider_state(auth_store, "zai", state)
# Persist failure (disk full, permissions, lock timeout) must not
# break resolution — detection already succeeded; worst case the
# next start re-probes.
try:
with _auth_store_lock():
# Reload auth_store under lock to avoid overwriting concurrent changes
auth_store = _load_auth_store()
state_under_lock = _load_provider_state(auth_store, "zai") or {}
state_under_lock["detected_endpoint"] = detected_endpoint
# set_active=False: this runs from credential-pool env seeding
# (agent/credential_pool.py) for ANY user with a Z.AI key in env,
# and caching a probe result must not flip their active provider.
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
_save_auth_store(auth_store)
except Exception as exc:
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
return detected["base_url"]
@@ -1495,6 +1510,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool:
if has_usable_secret(os.getenv(env_var, "")):
return True
# 4. Check persisted credential-pool entries that came from EXPLICIT flows
# the user initiated inside Hermes (manual add / device-code / PKCE), plus
# env-backed pool entries. This intentionally excludes ambient borrowed
# sources like gh_cli / claude_code / qwen-cli.
try:
for entry in read_credential_pool(normalized):
if not isinstance(entry, dict):
continue
source = str(entry.get("source") or "").strip().lower()
if not source:
continue
if source.startswith("env:"):
# A stale env-seeded pool entry survives in auth.json after
# the user deletes the env var (#55790) — only count it when
# the referenced var still resolves to a usable secret NOW.
env_var = entry.get("source", "").split(":", 1)[1].strip()
if env_var and has_usable_secret(os.getenv(env_var, "")):
return True
continue
if (
source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"}
or source.startswith("manual:")
):
return True
except Exception:
pass
return False
+17 -12
View File
@@ -2,7 +2,6 @@
Pure display functions with no HermesCLI state dependency.
"""
import json
import logging
import os
@@ -322,8 +321,8 @@ def check_for_updates() -> Optional[int]:
# both the Rich banner (build_welcome_banner) and the Ink badge
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "docker":
from hermes_cli.config import detect_install_method, get_project_root
if detect_install_method(get_project_root()) == "docker":
return None
except Exception:
pass
@@ -889,17 +888,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
except Exception:
pass # Never break the banner over an update check
# Pip-install warning — `pip install hermes-agent` is not the supported
# install path (it exists on PyPI for internal/CI reasons, not end users).
# Such installs miss the git checkout + installer-managed deps, so updates,
# self-update, and issue triage don't behave correctly. Warn, don't block.
# Unsupported install-method warning — pip/PyPI and Homebrew are no
# longer an officially supported distribution method (see
# website/docs/getting-started/platform-support.md). Such installs miss
# the git checkout + installer-managed deps, so updates, self-update, and
# issue triage don't behave correctly. Warn, don't block. NixOS is fully
# supported and never hits this.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "pip":
from hermes_cli.config import (
detect_install_method,
format_unsupported_install_warning,
is_unsupported_install_method,
get_project_root
)
_install_method = detect_install_method(get_project_root())
if is_unsupported_install_method(_install_method):
right_lines.append(
"[bold yellow]⚠ pip install not officially supported[/]"
"[dim yellow] — exists for reasons other than user install; "
"expect instability and an inability to support issues[/]"
f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]"
)
except Exception:
pass # Never break the banner over the install-method check
+63 -2
View File
@@ -441,8 +441,20 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
if (root / ".git").is_dir():
# detect git repo installs (normal installer, development env)
git_path = root / ".git"
if git_path.is_dir():
return "git"
# detect git repo installs from worktrees
if git_path.is_file():
try:
content = git_path.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
return "git"
except OSError:
pass
return "pip"
@@ -528,10 +540,54 @@ def recommended_update_command() -> str:
managed_cmd = get_managed_update_command()
if managed_cmd:
return managed_cmd
method = detect_install_method()
method = detect_install_method(get_project_root())
return recommended_update_command_for_method(method)
# =============================================================================
# Unsupported install methods (pip, Homebrew) — deprecation notice
# =============================================================================
#
# pip/PyPI and Homebrew are NOT an officially supported distribution method
# (see website/docs/getting-started/platform-support.md, "Unsupported"
# section). pip exists on PyPI for internal/CI reasons, not end-user installs;
# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode"
# (which hard-blocks config writes), this is a warn-don't-block deprecation
# notice surfaced everywhere the user might see install-method state: the CLI
# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS
# stays fully supported (Tier 2) and must never hit this path.
PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support"
_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"})
def is_unsupported_install_method(method: str) -> bool:
"""Whether ``method`` (from ``detect_install_method()``) is deprecated."""
return method in _UNSUPPORTED_INSTALL_METHODS
def unsupported_install_method_label(method: str) -> str:
"""Human-readable name for an unsupported install method."""
return "pip" if method == "pip" else "Homebrew"
def format_unsupported_install_warning(method: str) -> str:
"""Plain-text (no markup) deprecation notice for pip/Homebrew installs.
Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and
``hermes update`` / ``hermes update --check`` so the wording — and the
docs link — stays consistent across every surface instead of drifting
into three slightly different warnings.
"""
label = unsupported_install_method_label(method)
return (
f"{label} installs are no longer an officially supported platform and "
f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} "
"for supported install methods."
)
# Long-form text for ``hermes update`` / ``--check`` when running inside the
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
# hermes_cli/main.py; lives here so the wording stays consistent and we
@@ -2384,6 +2440,11 @@ DEFAULT_CONFIG = {
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
# When True, Discord approval prompts mention numeric allowed users so
# owners notice approval requests in shared channels/threads. Env
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
# pings.
"approval_mentions": False,
# Voice-channel audio effects (the continuous mixer). OFF by default.
# When enabled, the bot installs a software mixer on the outgoing voice
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
+23 -10
View File
@@ -4488,16 +4488,29 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
if not pid or not _pid_exists(pid):
return
# (c) default config has multiplexing on
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return
with open(cfg_path, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
multiplex = bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
)
# (c) multiplexing is on for the default gateway. Precedence mirrors
# gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over
# config.yaml when set to a recognized value, so a hosted gateway that
# forces multiplex on via env (with no multiplex_profiles in config.yaml)
# still trips this guard. A blank/unrecognized env value falls through
# to config.yaml.
from gateway.config import _env_multiplex_profiles_override
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
return # explicitly forced OFF by the operator env override
if env_multiplex is True:
multiplex = True
else:
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return
with open(cfg_path, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
multiplex = bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
)
if not multiplex:
return
except Exception:
+20 -3
View File
@@ -35,6 +35,7 @@ import os
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
@@ -85,6 +86,20 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
return raw
def _resolve_identity_token() -> str:
"""Resolve the caller-identity bearer token (generic-OIDC or Nous Portal).
Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and
the runtime self-provision path share ONE implementation (generic OAuth2
client-credentials when ``gateway.idp.token_url`` is set — the air-gapped /
self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure.
"""
from gateway.relay import _resolve_relay_identity_token
return _resolve_relay_identity_token()
def _post_enroll(
*,
connector_base_url: str,
@@ -179,9 +194,11 @@ def cmd_gateway_enroll(args) -> None:
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
# 1. Resolve a fresh Nous access token (the tenant-proving identity).
# 1. Resolve the caller-identity token (the tenant-proving identity). Generic
# OIDC client-credentials when an IdP token endpoint is configured (air-
# gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token.
try:
access_token = resolve_nous_access_token()
access_token = _resolve_identity_token()
except AuthError as exc:
if getattr(exc, "relogin_required", False):
print("✗ You're not logged into Nous Portal.")
@@ -190,7 +207,7 @@ def cmd_gateway_enroll(args) -> None:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
except Exception as exc:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
print(f"✗ Could not resolve a caller-identity token: {exc}")
sys.exit(1)
# 2-3. Redeem the enrollment token at the connector.
+39
View File
@@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext:
def build_models_payload(
ctx: ConfigContext,
*,
explicit_only: bool = False,
include_unconfigured: bool = False,
picker_hints: bool = False,
canonical_order: bool = False,
@@ -126,6 +127,10 @@ def build_models_payload(
needs from a single substrate call.
Flags:
- ``explicit_only``: keep only providers the user explicitly configured
(current provider, providers from config, or providers backed by
provider-specific env vars). This hides ambient / auto-seeded
credentials from desktop chat pickers.
- ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
``list_authenticated_providers`` didn't emit (TUI uses this to show
the full provider universe in the picker).
@@ -180,6 +185,9 @@ def build_models_payload(
if moa_row is not None:
rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
if explicit_only:
rows = _filter_explicit_provider_rows(rows, ctx)
# --- Deduplicate: remove models from aggregators that overlap with
# user-defined providers. When a local proxy (e.g. litellm-proxy)
# serves a model whose name also appears in an aggregator's curated
@@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict
return extras
def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
"""Keep only rows backed by explicit user configuration.
``list_authenticated_providers`` intentionally discovers ambient / auto-
seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model
pickers want the narrower subset the user explicitly configured for Hermes.
"""
from hermes_cli.auth import is_provider_explicitly_configured
current_slug = str(ctx.current_provider or "").strip().lower()
kept: list[dict] = []
for row in rows:
slug = str(row.get("slug", "")).strip().lower()
if not slug:
continue
if row.get("is_user_defined"):
kept.append(row)
continue
if current_slug and slug == current_slug:
kept.append(row)
continue
if slug == "moa":
# MoA is a virtual routing mode, not an independently configured
# provider. Hide it from explicit-only pickers unless it is the
# current provider (handled above).
continue
if is_provider_explicitly_configured(slug):
kept.append(row)
return kept
def _apply_picker_hints(rows: list[dict]) -> None:
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
+507 -41
View File
@@ -229,9 +229,9 @@ def _read_openai_version_fast() -> str | None:
def _print_fast_version_info() -> None:
from hermes_cli import __release_date__, __version__
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
print(f"Hermes Agent v{__version__} ({__release_date__})")
print(f"Project: {project_root}")
print(f"Install directory: {PROJECT_ROOT}")
print(f"Python: {sys.version.split()[0]}")
openai_version = _read_openai_version_fast()
@@ -4329,10 +4329,12 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
from hermes_cli.config import detect_install_method
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
print(f"Project: {PROJECT_ROOT}")
print(f"Install directory: {PROJECT_ROOT}")
print(f"Install method: {detect_install_method(PROJECT_ROOT)}")
# Show Python version
print(f"Python: {sys.version.split()[0]}")
@@ -8371,8 +8373,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
on a PyPI install we surface a one-line notice instead of silently
dropping the flag.
"""
from hermes_cli.config import detect_install_method
from hermes_cli.config import (
detect_install_method,
format_unsupported_install_warning,
is_unsupported_install_method,
)
method = detect_install_method(PROJECT_ROOT)
if is_unsupported_install_method(method):
print(f"⚠ {format_unsupported_install_warning(method)}")
if method == "docker":
# Docker can't ``git fetch`` from within the container. Surface the
# same long-form ``docker pull`` guidance ``hermes update`` (apply
@@ -9255,10 +9263,21 @@ def cmd_update(args):
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
format_unsupported_install_warning,
is_managed,
is_unsupported_install_method,
managed_error,
)
# Deprecation notice for pip/Homebrew installs — printed before the
# managed-mode early-return below so Homebrew users (who are blocked from
# applying the update here) still see it. Warn, don't block: the update
# itself still proceeds (except Homebrew, which is managed-mode blocked
# for an unrelated reason — brew owns its own upgrade path).
_install_method_for_warning = detect_install_method(PROJECT_ROOT)
if is_unsupported_install_method(_install_method_for_warning):
print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}")
if is_managed():
managed_error("update Hermes Agent")
return
@@ -13433,23 +13452,6 @@ def main():
"--limit", type=int, default=20, help="Max sessions to show"
)
sessions_export = sessions_subparsers.add_parser(
"export", help="Export sessions to a JSONL file"
)
sessions_export.add_argument(
"output", help="Output JSONL file path (use - for stdout)"
)
sessions_export.add_argument("--source", help="Filter by source")
sessions_export.add_argument("--session-id", help="Export a specific session")
sessions_delete = sessions_subparsers.add_parser(
"delete", help="Delete a specific session"
)
sessions_delete.add_argument("session_id", help="Session ID to delete")
sessions_delete.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
)
def _add_session_filter_args(p, default_older_help):
p.add_argument(
"--older-than",
@@ -13547,6 +13549,93 @@ def main():
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_export = sessions_subparsers.add_parser(
"export", help="Export sessions to JSONL, Markdown, or QMD"
)
sessions_export.add_argument(
"output",
nargs="?",
help=(
"Output path. JSONL: file path (use - for stdout, required). "
"md/qmd: output directory (default: <hermes home>/session-exports)"
),
)
sessions_export.add_argument(
"--format",
choices=["jsonl", "md", "qmd", "html", "trace"],
default="jsonl",
help=(
"Export format (default: jsonl). 'trace' emits Claude Code JSONL "
"for the Hugging Face Agent Trace Viewer"
),
)
sessions_export.add_argument(
"--upload",
action="store_true",
help=(
"trace only: upload to your Hugging Face traces dataset instead "
"of writing a local file (needs HF_TOKEN)"
),
)
sessions_export.add_argument(
"--public",
action="store_true",
help="trace --upload only: create/update a public dataset instead of private",
)
sessions_export.add_argument(
"--no-redact",
action="store_true",
help=(
"trace only: skip the forced secret redaction; "
"only use after manual review"
),
)
sessions_export.add_argument(
"--only",
choices=["user-prompts"],
help=(
"Export only a filtered view (user-prompts: one prompt record "
"per line for jsonl, headed sections for md)"
),
)
sessions_export.add_argument(
"--session-id", help="Session ID or unique prefix to export"
)
_add_session_filter_args(
sessions_export,
"Only export sessions older than AGE (duration like '5h'/'2d', "
"bare number of days, or an ISO timestamp)",
)
sessions_export.add_argument(
"--redact",
action="store_true",
help="Redact secrets (API keys, tokens, credentials) from exported content",
)
sessions_export.add_argument(
"--lineage",
choices=["single", "logical"],
default="single",
help="md/qmd only: export one row or its compression lineage",
)
sessions_export.add_argument(
"--delete-after-verified",
action="store_true",
help="md/qmd only: after verified single-session export, delete that session (needs --yes)",
)
sessions_export.add_argument(
"--force",
action="store_true",
help="md/qmd only: overwrite an existing export file",
)
sessions_delete = sessions_subparsers.add_parser(
"delete", help="Delete a specific session"
)
sessions_delete.add_argument("session_id", help="Session ID to delete")
sessions_delete.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_prune = sessions_subparsers.add_parser(
"prune",
help="Delete old sessions (filterable by time window, source, title, ...)",
@@ -13717,34 +13806,411 @@ def main():
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
elif action == "export":
from hermes_cli.session_filters import (
build_prune_filters,
describe_filters,
)
_filter_arg_names = (
"older_than", "newer_than", "before", "after",
"source", "title", "end_reason", "cwd",
"min_messages", "max_messages", "model", "provider",
"user", "chat_id", "chat_type", "branch",
"min_tokens", "max_tokens", "min_cost", "max_cost",
"min_tool_calls", "max_tool_calls",
)
_any_filters = any(
getattr(args, a, None) is not None for a in _filter_arg_names
)
filters = None
if _any_filters:
try:
filters = build_prune_filters(args)
except ValueError as e:
print(f"Error: {e}")
return
# Unlike prune/archive, export includes archived sessions.
filters["archived"] = None
def _redact(data):
if not args.redact or data is None:
return data
from hermes_cli.session_export_md import redact_session_data
return redact_session_data(data)
def _collect_sessions():
"""Resolve --session-id / filters / bare export into a list
of redacted session dicts, or None after printing an error."""
if args.session_id:
resolved = db.resolve_session_id(args.session_id)
data = _redact(db.export_session(resolved)) if resolved else None
if not data:
print(f"Session '{args.session_id}' not found.")
return None
return [data]
if filters:
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
print(
f"Would export {len(candidates)} session(s) "
f"({describe_filters(filters)})."
)
for row in candidates[:100]:
print(f" {row.get('id')} {row.get('source', '')}")
if len(candidates) > 100:
print(f" ... {len(candidates) - 100} more")
return None
return [
s
for s in (
_redact(db.export_session(row["id"])) for row in candidates
)
if s
]
if args.dry_run:
print("--dry-run requires at least one filter.")
return None
return [_redact(s) for s in db.export_all(source=None)]
# Prompt-only export (--only user-prompts): one prompt record per
# line (jsonl) or headed sections (md). Delegates rendering to
# hermes_cli.session_export.
if getattr(args, "only", None):
if args.format not in ("jsonl", "md"):
print("--only user-prompts supports --format jsonl or md.")
return
from hermes_cli.session_export import (
export_record_count,
render_sessions_export,
)
sessions = _collect_sessions()
if sessions is None:
db.close()
return
rendered = render_sessions_export(
sessions,
fmt="markdown" if args.format == "md" else "jsonl",
only=args.only,
)
if not args.output or args.output == "-":
sys.stdout.write(rendered)
db.close()
return
with open(args.output, "w", encoding="utf-8") as f:
f.write(rendered)
count, noun = export_record_count(sessions, only=args.only)
suffix = "" if count == 1 else "s"
print(f"Exported {count} {noun}{suffix} to {args.output}")
db.close()
return
# Standalone HTML export: one self-contained file (single session
# or multi-session with sidebar navigation).
if args.format == "html":
if not args.output or args.output == "-":
print("HTML export requires an output file path.")
return
from hermes_cli.session_export_html import (
generate_html_export,
generate_multi_session_html_export,
)
sessions = _collect_sessions()
if sessions is None:
db.close()
return
if len(sessions) == 1:
content = generate_html_export(sessions[0])
else:
content = generate_multi_session_html_export(sessions)
with open(args.output, "w", encoding="utf-8") as f:
f.write(content)
suffix = "" if len(sessions) == 1 else "s"
print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)")
db.close()
return
# Claude Code JSONL trace export — local file or HF upload.
# Redaction is ON by default for traces (they leave the machine
# when --upload is used); --no-redact opts out after review.
if args.format == "trace":
if getattr(args, "only", None):
print("--only user-prompts supports --format jsonl or md.")
db.close()
return
session_id = args.session_id
if not session_id and not filters:
# Match the shell's common intent: "the last thing I did".
rows = db.list_sessions_rich(limit=1, order_by_last_active=True)
session_id = rows[0].get("id") if rows else None
if not session_id:
print("No session found to export. Pass --session-id.")
db.close()
return
if session_id and not db.resolve_session_id(session_id):
print(f"Session '{session_id}' not found.")
db.close()
return
from agent.trace_upload import (
TraceRedactionError,
build_trace_jsonl,
upload_session_trace,
)
redact_trace = not getattr(args, "no_redact", False)
if getattr(args, "upload", False):
if not session_id:
print("--upload exports one session: pass --session-id (or drop filters to use the most recent).")
db.close()
return
resolved = db.resolve_session_id(session_id)
db.close()
status = upload_session_trace(
resolved,
cwd="",
redact=redact_trace,
private=not getattr(args, "public", False),
)
print(status)
return
# Local trace file(s)
def _trace_ids():
if session_id:
return [db.resolve_session_id(session_id)]
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
print(
f"Would export {len(candidates)} session(s) "
f"({describe_filters(filters)})."
)
for row in candidates[:100]:
print(f" {row.get('id')} {row.get('source', '')}")
if len(candidates) > 100:
print(f" ... {len(candidates) - 100} more")
return None
return [row["id"] for row in candidates]
ids = _trace_ids()
if ids is None:
db.close()
return
def _render_trace(sid):
meta = db.get_session(sid) or {}
messages = db.get_messages_as_conversation(sid)
if not messages:
return None
return build_trace_jsonl(
messages,
session_id=sid,
model=meta.get("model") or "",
cwd="",
redact=redact_trace,
)
try:
if len(ids) == 1:
jsonl = _render_trace(ids[0])
if not jsonl:
print(f"No transcript to export for session '{ids[0]}'.")
db.close()
return
if not args.output or args.output == "-":
sys.stdout.write(jsonl)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(jsonl)
print(f"Exported 1 session trace to {args.output}")
else:
out_dir = (
Path(args.output).expanduser()
if args.output and args.output != "-"
else get_hermes_home() / "session-exports"
)
out_dir.mkdir(parents=True, exist_ok=True)
exported = 0
for sid in ids:
jsonl = _render_trace(sid)
if not jsonl:
continue
(out_dir / f"{sid}.trace.jsonl").write_text(
jsonl, encoding="utf-8"
)
exported += 1
print(f"Exported {exported} session trace(s) to {out_dir}")
except TraceRedactionError:
print("Redaction failed; refusing to export unredacted trace content.")
db.close()
return
if args.format == "jsonl":
if not args.output:
print("JSONL export requires an output path (use - for stdout).")
return
if args.session_id:
resolved_session_id = db.resolve_session_id(args.session_id)
if not resolved_session_id:
print(f"Session '{args.session_id}' not found.")
return
data = _redact(db.export_session(resolved_session_id))
if not data:
print(f"Session '{args.session_id}' not found.")
return
line = _json.dumps(data, ensure_ascii=False) + "\n"
if args.output == "-":
sys.stdout.write(line)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(line)
print(f"Exported 1 session to {args.output}")
else:
if filters:
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
print(
f"Would export {len(candidates)} session(s) "
f"({describe_filters(filters)})."
)
for row in candidates[:100]:
print(f" {row.get('id')} {row.get('source', '')}")
if len(candidates) > 100:
print(f" ... {len(candidates) - 100} more")
return
sessions = [
s
for s in (
db.export_session(row["id"]) for row in candidates
)
if s
]
else:
if args.dry_run:
print("--dry-run requires at least one filter.")
return
sessions = db.export_all(source=None)
if args.output == "-":
for s in sessions:
sys.stdout.write(
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
)
else:
with open(args.output, "w", encoding="utf-8") as f:
for s in sessions:
f.write(
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
)
print(f"Exported {len(sessions)} sessions to {args.output}")
return
# Markdown / QMD export
from hermes_cli.session_export_md import (
append_manifest_entry,
verify_export_file,
write_session_markdown,
)
if args.output == "-":
print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.")
db.close()
return
output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports"
def _export_one(session_id: str):
data = (
db.export_session_lineage(session_id)
if getattr(args, "lineage", "single") == "logical"
else db.export_session(session_id)
)
if not data:
return None, None
data = _redact(data)
path = write_session_markdown(
data,
output_dir,
fmt=args.format,
force=args.force,
)
append_manifest_entry(output_dir, data, path, fmt=args.format)
return data, path
if args.delete_after_verified and not args.yes:
print("--delete-after-verified requires --yes.")
db.close()
return
if args.delete_after_verified and not args.session_id:
print("--delete-after-verified is only supported with --session-id.")
db.close()
return
if args.session_id:
resolved_session_id = db.resolve_session_id(args.session_id)
if not resolved_session_id:
print(f"Session '{args.session_id}' not found.")
db.close()
return
data = db.export_session(resolved_session_id)
if not data:
try:
data, exported_path = _export_one(resolved_session_id)
except FileExistsError as e:
print(f"Export already exists: {e}. Pass --force to overwrite.")
db.close()
return
if not data or not exported_path:
print(f"Session '{args.session_id}' not found.")
db.close()
return
line = _json.dumps(data, ensure_ascii=False) + "\n"
if args.output == "-":
message_count = len(data.get("messages") or [])
suffix = "" if message_count == 1 else "s"
print(f"Exported 1 session ({message_count} message{suffix}) to {exported_path}")
if args.delete_after_verified:
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(f"Export verification failed; not deleting: {reason}")
db.close()
return
sessions_dir = get_hermes_home() / "sessions"
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
print(f"Deleted exported session '{resolved_session_id}'.")
else:
print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.")
db.close()
return
sys.stdout.write(line)
else:
with open(args.output, "w", encoding="utf-8") as f:
f.write(line)
print(f"Exported 1 session to {args.output}")
else:
sessions = db.export_all(source=args.source)
if args.output == "-":
for s in sessions:
sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n")
else:
with open(args.output, "w", encoding="utf-8") as f:
for s in sessions:
f.write(_json.dumps(s, ensure_ascii=False) + "\n")
print(f"Exported {len(sessions)} sessions to {args.output}")
if not filters:
print(
"Refusing bulk export without a filter. Pass --session-id or "
"at least one filter (e.g. --older-than 90, --source telegram)."
)
db.close()
return
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
print(
f"Would export {len(candidates)} session(s) "
f"({describe_filters(filters)})."
)
for row in candidates[:100]:
print(f" {row.get('id')} {row.get('source', '')}")
if len(candidates) > 100:
print(f" ... {len(candidates) - 100} more")
db.close()
return
exported = 0
for row in candidates:
try:
data, exported_path = _export_one(row["id"])
except FileExistsError as e:
print(f"Skipping existing export: {e}. Pass --force to overwrite.")
continue
if data and exported_path:
exported += 1
print(f"Exported {exported} session(s) to {output_dir}")
elif action == "delete":
resolved_session_id = db.resolve_session_id(args.session_id)
+3 -4
View File
@@ -67,7 +67,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
# Tencent
("tencent/hy3-preview", ""),
("tencent/hy3", ""),
# StepFun
("stepfun/step-3.7-flash", ""),
# NVIDIA
@@ -78,9 +78,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("poolside/laguna-m.1:free", "free"),
("tencent/hy3-preview:free", "free"),
("tencent/hy3:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("inclusionai/ring-2.6-1t:free", "free"),
@@ -211,7 +210,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# Xiaomi
"xiaomi/mimo-v2.5-pro",
# Tencent
"tencent/hy3-preview",
"tencent/hy3",
# StepFun
"stepfun/step-3.7-flash",
# NVIDIA
+147 -12
View File
@@ -2077,6 +2077,13 @@ def has_hook(hook_name: str) -> bool:
_thread_tool_whitelist = threading.local()
@dataclass(frozen=True)
class _PreToolCallDirective:
action: Optional[str] = None
message: Optional[str] = None
rule_key: Optional[str] = None
def set_thread_tool_whitelist(
allowed: Optional[Set[str]],
deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist",
@@ -2089,7 +2096,7 @@ def clear_thread_tool_whitelist() -> None:
_thread_tool_whitelist.allowed = None
def get_pre_tool_call_block_message(
def _get_pre_tool_call_directive_details(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
@@ -2098,22 +2105,40 @@ def get_pre_tool_call_block_message(
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Check ``pre_tool_call`` hooks for a blocking directive.
) -> _PreToolCallDirective:
"""Check ``pre_tool_call`` hooks for a blocking or approval directive.
Plugins that need to enforce policy (rate limiting, security
restrictions, approval workflows) can return::
restrictions, approval workflows) can return one of::
{"action": "block", "message": "Reason the tool was blocked"}
{"action": "block", "message": "Reason the tool was blocked"}
{"action": "approve", "message": "Why this needs human confirmation"}
{"action": "approve", "message": "...", "rule_key": "write_file:ssh"}
from their ``pre_tool_call`` callback. The first valid block
directive wins. Invalid or irrelevant hook return values are
silently ignored so existing observer-only hooks are unaffected.
from their ``pre_tool_call`` callback.
- ``block`` vetoes the tool call outright (the message becomes the tool
result the model sees).
- ``approve`` ESCALATES to the existing human-approval gate
(``prompt_dangerous_approval`` on CLI, the approval callback on the
gateway) — the same mechanism Tier-2 dangerous shell patterns use.
This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny``
decision on ANY tool, not just terminal command strings. The caller is
responsible for invoking the gate (see
:func:`tools.approval.request_tool_approval`).
- ``rule_key`` is optional and only honored for ``approve`` directives. It
lets plugins choose the allowlist grain for `[a]lways` approvals.
The first valid directive wins. Invalid or irrelevant hook return values
are silently ignored so existing observer-only hooks are unaffected.
"""
allowed = getattr(_thread_tool_whitelist, "allowed", None)
if allowed is not None and tool_name not in allowed:
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
return fmt.format(tool_name=tool_name)
return _PreToolCallDirective(
action="block",
message=fmt.format(tool_name=tool_name),
)
hook_results = invoke_hook(
"pre_tool_call",
@@ -2130,12 +2155,122 @@ def get_pre_tool_call_block_message(
for result in hook_results:
if not isinstance(result, dict):
continue
if result.get("action") != "block":
action = result.get("action")
if action not in ("block", "approve"):
continue
message = result.get("message")
if isinstance(message, str) and message:
return message
message = message if isinstance(message, str) and message else None
# A block directive requires a message (it becomes the tool result);
# an approve directive can carry an optional reason.
if action == "block" and not message:
continue
rule_key = result.get("rule_key") if action == "approve" else None
rule_key = rule_key.strip() if isinstance(rule_key, str) else None
if not rule_key:
rule_key = None
return _PreToolCallDirective(action=action, message=message, rule_key=rule_key)
return _PreToolCallDirective()
def get_pre_tool_call_directive(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Check ``pre_tool_call`` hooks for a blocking or approval directive.
Backward-compatible public helper: returns ``(directive, message)`` where
``directive`` is ``"block"``, ``"approve"``, or ``None``. Internal callers
that need approve-specific metadata use
:func:`_get_pre_tool_call_directive_details`.
"""
details = _get_pre_tool_call_directive_details(
tool_name, args, task_id=task_id, session_id=session_id,
tool_call_id=tool_call_id, turn_id=turn_id,
api_request_id=api_request_id, middleware_trace=middleware_trace,
)
return (details.action, details.message)
def get_pre_tool_call_block_message(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Back-compat shim: return only a ``block`` message (or ``None``).
Deprecated in favor of :func:`get_pre_tool_call_directive`, which also
surfaces the ``approve`` escalation directive. Kept so any external caller
importing the old name keeps working; ``approve`` directives are invisible
to this shim (it only reports blocks).
"""
directive, message = get_pre_tool_call_directive(
tool_name, args, task_id=task_id, session_id=session_id,
tool_call_id=tool_call_id, turn_id=turn_id,
api_request_id=api_request_id, middleware_trace=middleware_trace,
)
return message if directive == "block" else None
def resolve_pre_tool_block(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Resolve the pre_tool_call directive to a final block message (or None).
Single entry point for every tool-dispatch site: fetches the plugin
directive and, for an ``approve`` escalation, invokes the human-approval
gate (:func:`tools.approval.request_tool_approval`). Returns the message
the tool result should carry when the call is blocked, or ``None`` when
the call may proceed.
Centralizing this keeps the security-critical fail-closed logic in ONE
place instead of copy-pasted across the concurrent/sequential/helper
dispatch paths: an ``approve`` directive whose gate errors, denies, or
times out is fail-closed to a block; ``block`` blocks with its message;
anything else proceeds.
"""
details = _get_pre_tool_call_directive_details(
tool_name, args, task_id=task_id, session_id=session_id,
tool_call_id=tool_call_id, turn_id=turn_id,
api_request_id=api_request_id, middleware_trace=middleware_trace,
)
if details.action == "block":
return details.message
if details.action == "approve":
try:
from tools.approval import request_tool_approval
result = request_tool_approval(
tool_name,
details.message or "",
rule_key=details.rule_key or tool_name,
)
except Exception:
# Fail-closed: if the gate itself errors, block rather than
# silently execute an action a plugin flagged for approval.
return f"BLOCKED: plugin approval gate failed for {tool_name}"
if not result.get("approved"):
return str(
result.get("message")
or f"BLOCKED: plugin approval required for {tool_name}"
)
return None
+195
View File
@@ -0,0 +1,195 @@
"""Keep-alive PTY sessions for dashboard terminals.
A PTY process outlives the WebSocket that created it: a single drain task
always reads the PTY into a bounded RingBuffer and forwards to the attached
socket when present. Reconnecting with the same opaque token replays the
buffer and resumes live. See
docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md.
"""
from __future__ import annotations
import asyncio
import time
from typing import Optional
WS_CLOSE_PROCESS_EXITED = 4410
WS_CLOSE_SUPERSEDED = 4409
class RingBuffer:
"""Keeps only the most recent ``capacity`` bytes appended to it."""
def __init__(self, capacity: int) -> None:
self._cap = capacity
self._buf = bytearray()
self._truncated = False
def append(self, data: bytes) -> None:
self._buf.extend(data)
overflow = len(self._buf) - self._cap
if overflow > 0:
del self._buf[:overflow]
self._truncated = True
def snapshot(self) -> bytes:
return bytes(self._buf)
@property
def truncated(self) -> bool:
return self._truncated
class PtySession:
def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float) -> None:
self.key = key
self.bridge = bridge
self.buffer = RingBuffer(buffer_cap)
self.alive = True
self.attached = False
self.last_detached_at: Optional[float] = None
self._read_timeout = read_timeout
self._ws = None
self._drain_task: Optional[asyncio.Task] = None
async def start(self) -> None:
self._drain_task = asyncio.create_task(self._drain())
async def _drain(self) -> None:
loop = asyncio.get_running_loop()
while True:
chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout)
if chunk is None: # EOF — the agent process exited
self.alive = False
ws = self._ws
if ws is not None:
try:
await ws.close(code=WS_CLOSE_PROCESS_EXITED)
except Exception:
pass
return
if not chunk: # idle tick
await asyncio.sleep(0)
continue
self.buffer.append(chunk)
ws = self._ws
if ws is not None:
try:
await ws.send_bytes(chunk)
except Exception:
pass # detached mid-send; keep buffering
async def attach(self, ws) -> None:
old = self._ws
if old is not None and old is not ws:
try:
await old.close(code=WS_CLOSE_SUPERSEDED)
except Exception:
pass
self._ws = ws
self.attached = True
self.last_detached_at = None
snap = self.buffer.snapshot()
if snap:
await ws.send_bytes(snap)
def detach(self, ws) -> None:
# Only the currently-attached socket may mark the session detached.
# A superseded socket's handler also calls detach on its way out
# (its ``finally`` runs after the new tab attached); flipping
# ``attached`` then would make a session with a live viewer look
# idle and reapable.
if self._ws is not ws:
return
self._ws = None
self.attached = False
self.last_detached_at = time.monotonic()
async def close(self) -> None:
if self._drain_task is not None:
self._drain_task.cancel()
try:
await self._drain_task
except (asyncio.CancelledError, Exception):
pass
try:
# bridge.close() joins the child — blocking; keep it off the
# event loop (#53227).
await asyncio.to_thread(self.bridge.close)
except Exception:
pass
from typing import Callable, Dict, Tuple
class RegistryFull(Exception):
pass
async def run_reaper(registry: "PtySessionRegistry", *, interval: float = 60.0) -> None:
"""Periodically reap idle/dead keep-alive sessions. Cancelled on shutdown."""
while True:
await asyncio.sleep(interval)
try:
await registry.reap_idle()
except Exception:
pass
class PtySessionRegistry:
def __init__(self, *, ttl: float, max_sessions: int,
buffer_cap: int, read_timeout: float) -> None:
self._ttl = ttl
self._max = max_sessions
self._buffer_cap = buffer_cap
self._read_timeout = read_timeout
self._sessions: Dict[str, PtySession] = {}
async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object]
) -> Tuple[PtySession, bool]:
await self.reap_idle()
existing = self._sessions.get(key)
if existing is not None and existing.alive:
return existing, False
if existing is not None: # dead remnant
await existing.close()
self._sessions.pop(key, None)
if len(self._sessions) >= self._max:
self._reap_one_idle_or_raise()
# PTY spawn does blocking fork/exec work — keep it off the event
# loop (#53227).
bridge = await asyncio.to_thread(spawn)
session = PtySession(key, bridge, buffer_cap=self._buffer_cap,
read_timeout=self._read_timeout)
await session.start()
self._sessions[key] = session
return session, True
def detach(self, key: str, ws) -> None:
s = self._sessions.get(key)
if s is not None:
s.detach(ws)
async def reap_idle(self, now: Optional[float] = None) -> None:
now = time.monotonic() if now is None else now
doomed = [
key for key, s in self._sessions.items()
if (not s.alive)
or (not s.attached and s.last_detached_at is not None
and (now - s.last_detached_at) > self._ttl)
]
for key in doomed:
await self._sessions.pop(key).close()
def _reap_one_idle_or_raise(self) -> None:
idle = [s for s in self._sessions.values()
if not s.attached and s.last_detached_at is not None]
if not idle:
raise RegistryFull()
oldest = min(idle, key=lambda s: s.last_detached_at or 0.0)
self._sessions.pop(oldest.key, None)
asyncio.create_task(oldest.close())
async def close_all(self) -> None:
for key in list(self._sessions):
await self._sessions.pop(key).close()
+317
View File
@@ -0,0 +1,317 @@
"""Shared renderers for session export commands.
The CLI, dashboard, and slash-command surfaces all deal with the same
session-shaped data: a session dict with a ``messages`` list. Keep filtering
and human-readable rendering here so each surface only has to load sessions
and write bytes.
"""
from __future__ import annotations
from datetime import datetime, timezone
from html import escape as html_escape
import json
from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Tuple
ExportFormat = Literal["jsonl", "markdown"]
ExportOnly = Literal["user-prompts"]
def normalize_export_format(fmt: str) -> ExportFormat:
"""Return the canonical export format name."""
value = (fmt or "jsonl").strip().lower()
if value == "md":
value = "markdown"
if value not in {"jsonl", "markdown"}:
raise ValueError(f"Unsupported session export format: {fmt}")
return value # type: ignore[return-value]
def normalize_export_only(only: Optional[str]) -> Optional[ExportOnly]:
"""Return the canonical export filter name."""
if only is None:
return None
value = only.strip().lower()
if value in {"user", "prompts", "user-prompts", "user_prompts"}:
return "user-prompts"
raise ValueError(f"Unsupported session export filter: {only}")
def render_sessions_export(
sessions: Iterable[Dict[str, Any]],
*,
fmt: str = "jsonl",
only: Optional[str] = None,
) -> str:
"""Render exported sessions in a stable, reusable format.
``fmt=jsonl`` with no filter intentionally preserves the legacy shape:
one full session object per line. ``only=user-prompts`` switches the unit
of export to one prompt record per line so the output is easy to pipe into
review, memory-ingestion, or prompt-library tooling.
"""
session_list = list(sessions)
export_format = normalize_export_format(fmt)
export_only = normalize_export_only(only)
if export_format == "jsonl":
return _render_jsonl(session_list, only=export_only)
return _render_markdown(session_list, only=export_only)
def export_record_count(
sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None
) -> Tuple[int, str]:
"""Return ``(count, noun)`` for status messages after an export."""
session_list = list(sessions)
export_only = normalize_export_only(only)
if export_only == "user-prompts":
return sum(1 for _ in iter_user_prompt_records(session_list)), "prompt"
return len(session_list), "session"
def iter_user_prompt_records(
sessions: Iterable[Dict[str, Any]]
) -> Iterator[Dict[str, Any]]:
"""Yield one normalized record for each user-authored prompt."""
for session in sessions:
session_id = str(session.get("id") or session.get("session_id") or "")
index = 0
for message in _messages(session):
if message.get("role") != "user":
continue
index += 1
record: Dict[str, Any] = {
"session_id": session_id,
"index": index,
"created_at": _format_timestamp(message.get("timestamp")),
"role": "user",
"text": _message_text(message.get("content")),
}
message_id = message.get("id")
if message_id is not None:
record["message_id"] = message_id
event_id = message.get("platform_message_id") or message.get("event_id")
if event_id:
record["event_id"] = event_id
yield record
def _render_jsonl(
sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly]
) -> str:
if only == "user-prompts":
rows = iter_user_prompt_records(sessions)
else:
rows = iter(sessions)
lines = [json.dumps(row, ensure_ascii=False) for row in rows]
return ("\n".join(lines) + "\n") if lines else ""
def _render_markdown(
sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly]
) -> str:
if only == "user-prompts":
return _render_user_prompts_markdown(sessions)
return _render_full_markdown(sessions)
def _render_user_prompts_markdown(sessions: List[Dict[str, Any]]) -> str:
lines: List[str] = []
if len(sessions) == 1:
session = sessions[0]
lines.append(f"# User prompts for session {_heading_text(_session_id(session))}")
lines.extend(_session_metadata_lines(session))
lines.append("")
_append_prompt_records(lines, session, heading_level=2)
else:
lines.append("# User prompts export")
lines.append("")
for session in sessions:
lines.append(f"## Session {_heading_text(_session_id(session))}")
lines.extend(_session_metadata_lines(session))
lines.append("")
_append_prompt_records(lines, session, heading_level=3)
if not sessions:
lines.append("_No user prompts found._")
lines.append("")
return _finish_markdown(lines)
def _append_prompt_records(
lines: List[str], session: Dict[str, Any], *, heading_level: int
) -> None:
prompts = list(iter_user_prompt_records([session]))
if not prompts:
lines.append("_No user prompts found._")
lines.append("")
return
marker = "#" * heading_level
for prompt in prompts:
timestamp = prompt.get("created_at") or "timestamp unavailable"
lines.append(f"{marker} {prompt['index']}. {timestamp}")
message_id = prompt.get("message_id")
if message_id is not None:
lines.append(f"Message ID: `{message_id}`")
lines.append("")
lines.append(str(prompt.get("text") or ""))
lines.append("")
def _render_full_markdown(sessions: List[Dict[str, Any]]) -> str:
lines: List[str] = []
if len(sessions) == 1:
session = sessions[0]
lines.append(f"# Session: {_heading_text(_session_title_or_id(session))}")
lines.extend(_session_metadata_lines(session))
lines.append("")
_append_session_messages(lines, session, heading_level=2)
else:
lines.append("# Hermes sessions export")
lines.append("")
for session in sessions:
lines.append(f"## Session: {_heading_text(_session_title_or_id(session))}")
lines.extend(_session_metadata_lines(session))
lines.append("")
_append_session_messages(lines, session, heading_level=3)
return _finish_markdown(lines)
def _append_session_messages(
lines: List[str], session: Dict[str, Any], *, heading_level: int
) -> None:
marker = "#" * heading_level
visible_messages = [
message for message in _messages(session) if message.get("role") != "system"
]
if not visible_messages:
lines.append("_No messages found._")
lines.append("")
return
for message in visible_messages:
role = str(message.get("role") or "unknown")
timestamp = _format_timestamp(message.get("timestamp"))
suffix = f" - {timestamp}" if timestamp else ""
if role == "tool":
tool_name = str(message.get("tool_name") or message.get("name") or "tool")
lines.append(f"{marker} Tool: {_heading_text(tool_name)}{suffix}")
lines.append("")
lines.append(f"<details><summary>{html_escape(tool_name)}</summary>")
lines.append("")
lines.append(_fenced_text(_message_text(message.get("content"))))
lines.append("")
lines.append("</details>")
lines.append("")
continue
label = {
"user": "User",
"assistant": "Assistant",
}.get(role, role.title())
lines.append(f"{marker} {label}{suffix}")
lines.append("")
lines.append(_message_text(message.get("content")))
lines.append("")
def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]:
messages = session.get("messages") or []
return [message for message in messages if isinstance(message, dict)]
def _message_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [_content_part_text(part) for part in content]
return "\n".join(part for part in parts if part)
if isinstance(content, dict):
for key in ("text", "content"):
value = content.get(key)
if isinstance(value, str):
return value
return json.dumps(content, ensure_ascii=False, sort_keys=True)
return str(content)
def _content_part_text(part: Any) -> str:
if isinstance(part, str):
return part
if isinstance(part, dict):
for key in ("text", "content"):
value = part.get(key)
if isinstance(value, str):
return value
return json.dumps(part, ensure_ascii=False, sort_keys=True)
return str(part)
def _format_timestamp(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, (int, float)):
return (
datetime.fromtimestamp(float(value), tz=timezone.utc)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
if isinstance(value, datetime):
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds").replace(
"+00:00", "Z"
)
return str(value)
def _session_metadata_lines(session: Dict[str, Any]) -> List[str]:
lines: List[str] = [f"- Session ID: `{_session_id(session)}`"]
source = session.get("source")
if source:
lines.append(f"- Source: `{source}`")
model = session.get("model")
if model:
lines.append(f"- Model: `{model}`")
title = session.get("title")
if title:
lines.append(f"- Title: {_inline_text(str(title))}")
started = _format_timestamp(session.get("started_at"))
if started:
lines.append(f"- Started: {started}")
message_count = session.get("message_count")
if message_count is not None:
lines.append(f"- Messages: {message_count}")
return lines
def _session_id(session: Dict[str, Any]) -> str:
return str(session.get("id") or session.get("session_id") or "unknown")
def _session_title_or_id(session: Dict[str, Any]) -> str:
title = str(session.get("title") or "").strip()
return title or _session_id(session)
def _heading_text(value: str) -> str:
return " ".join(str(value).splitlines()).strip() or "unknown"
def _inline_text(value: str) -> str:
return " ".join(value.splitlines()).strip()
def _fenced_text(text: str, *, language: str = "text") -> str:
fence = "```"
while fence in text:
fence += "`"
return f"{fence}{language}\n{text}\n{fence}"
def _finish_markdown(lines: List[str]) -> str:
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n"
+849
View File
@@ -0,0 +1,849 @@
"""
HTML Export generator for Hermes sessions.
Generates a standalone, beautiful HTML file with all messages embedded.
Supports single and multi-session exports with a professional sidebar.
No remote dependencies.
Enhanced with UI-UX-PRO-MAX design intelligence.
"""
import json
import datetime
from typing import Any, Dict, List
# --- Icons (Lucide-style SVGs) ---
ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
ICON_BOT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>'
ICON_TERMINAL = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>'
ICON_WRENCH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wrench"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>'
ICON_SPARKLES = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>'
ICON_CHEVRON_RIGHT = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>'
ICON_SEARCH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>'
ICON_SHIELD = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1z"/></svg>'
ICON_HERMES = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFD700" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>'
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{page_title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {{
--bg-color: #F8FAFC;
--text-color: #0F172A;
--secondary-text: #475569;
--user-bg: #FFFFFF;
--assistant-bg: #F1F5F9;
--border-color: #E2E8F0;
--accent-color: #CD7F32;
--accent-foreground: #FFFFFF;
--code-bg: #1E293B;
--code-text: #F8FAFC;
--reasoning-bg: #FFFBEB;
--reasoning-border: #FEF3C7;
--tool-bg: #F0F9FF;
--tool-border: #E0F2FE;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--sidebar-bg: #FFFFFF;
--sidebar-width: 320px;
}}
@media (prefers-color-scheme: dark) {{
:root {{
--bg-color: #000101;
--text-color: #FFF8DC;
--secondary-text: #94A3B8;
--user-bg: #041c1c;
--assistant-bg: #0c1a1a;
--border-color: #CD7F32;
--accent-color: #FFD700;
--code-bg: #000000;
--reasoning-bg: #1a1a1a;
--reasoning-border: #CD7F32;
--tool-bg: #0c4a6e;
--tool-border: #075985;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--sidebar-bg: #041c1c;
}}
}}
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--bg-color);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}}
.layout {{
display: flex;
min-height: 100vh;
}}
/* Sidebar */
.sidebar {{
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 100;
transition: transform 0.3s ease;
}}
@media (max-width: 768px) {{
.sidebar {{
transform: translateX(-100%);
}}
.sidebar.open {{
transform: translateX(0);
}}
.main-content {{
margin-left: 0 !important;
}}
}}
.sidebar-header {{
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}}
.sidebar-brand {{
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.25rem;
color: var(--accent-color);
margin-bottom: 1rem;
}}
.search-container {{
position: relative;
}}
.search-container input {{
width: 100%;
padding: 0.5rem 1rem 0.5rem 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--bg-color);
color: var(--text-color);
font-size: 0.875rem;
outline: none;
}}
.search-container svg {{
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--secondary-text);
}}
.session-list {{
flex: 1;
overflow-y: auto;
padding: 1rem;
}}
.session-item {{
display: block;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
color: inherit;
border: 1px solid transparent;
}}
.session-item:hover {{
background-color: rgba(0, 0, 0, 0.05);
}}
@media (prefers-color-scheme: dark) {{
.session-item:hover {{
background-color: rgba(255, 255, 255, 0.05);
}}
}}
.session-item.active {{
background-color: var(--user-bg);
border-color: var(--accent-color);
box-shadow: var(--shadow);
}}
.session-item-title {{
font-weight: 600;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.25rem;
}}
.session-item-meta {{
font-size: 0.75rem;
color: var(--secondary-text);
display: flex;
justify-content: space-between;
}}
/* Main Content */
.main-content {{
flex: 1;
margin-left: {main_margin};
padding: 3rem 2rem;
max-width: 100%;
transition: margin-left 0.3s ease;
}}
.session-view {{
display: none;
width: 100%;
margin: 0 auto;
}}
.layout-single .session-view {{
max-width: 90%;
margin: 0 auto;
}}
.layout-multi .session-view {{
max-width: 100%;
margin: 0;
}}
.layout-multi .main-content {{
width: 0;
}}
.session-view.active {{
display: block;
}}
header {{
margin-bottom: 3rem;
text-align: center;
}}
.meta {{
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--secondary-text);
}}
.meta-item strong {{
color: var(--text-color);
}}
/* Messages */
.message {{
margin-bottom: 1.5rem;
border-radius: 0.75rem;
background-color: var(--user-bg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
overflow: hidden;
}}
.message-user {{
background-color: #f0fdf4;
}}
.message-assistant {{
background-color: var(--assistant-bg);
border-left: 4px solid var(--accent-color);
}}
.message-system {{
background-color: var(--bg-color);
border-left: 4px solid var(--secondary-text);
opacity: 0.9;
}}
.message-tool {{
background-color: #f8fafc;
border-style: dotted;
}}
@media (prefers-color-scheme: dark) {{
.message-user {{
background-color: #0c2121;
}}
.message-assistant {{
background-color: #041c1c;
}}
.message-system {{
background-color: #020617;
border-left: 4px solid var(--secondary-text);
}}
.message-tool {{
background-color: #0f172a;
}}
}}
.message-header {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
}}
.message-header:hover {{
background-color: rgba(0,0,0,0.02);
}}
.message-header svg.chevron {{
transition: transform 0.2s ease;
color: var(--secondary-text);
}}
.message.active svg.chevron {{
transform: rotate(90deg);
}}
.role-badge {{
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--secondary-text);
}}
.role-badge svg {{
color: var(--accent-color);
}}
.timestamp {{
font-size: 0.75rem;
color: var(--secondary-text);
font-variant-numeric: tabular-nums;
}}
.message-body {{
display: none;
padding: 0 1.5rem 1.5rem 1.5rem;
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
}}
.message.active .message-body {{
display: block;
}}
.content {{
white-space: pre-wrap;
word-break: break-word;
font-size: 1rem;
}}
/* Code Blocks */
code {{
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
background-color: rgba(0,0,0,0.05);
padding: 0.2rem 0.4rem;
border-radius: 0.375rem;
font-size: 0.9em;
}}
pre {{
background-color: var(--code-bg);
color: var(--code-text);
padding: 1.25rem;
border-radius: 0.75rem;
overflow-x: auto;
margin: 1.25rem 0;
font-size: 0.9rem;
line-height: 1.5;
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.1);
}}
pre code {{
background-color: transparent;
padding: 0;
border-radius: 0;
color: inherit;
}}
/* Tool Calls */
.tool-call {{
margin: 1rem 0;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--tool-border);
background-color: var(--tool-bg);
}}
.tool-call-header {{
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-weight: 600;
}}
.tool-call-header:hover {{
background-color: rgba(0,0,0,0.03);
}}
.tool-call-header svg.chevron {{
transition: transform 0.2s ease;
color: var(--secondary-text);
}}
.tool-call.active svg.chevron {{
transform: rotate(90deg);
}}
.tool-call-content {{
display: none;
padding: 0 1rem 1rem 1rem;
}}
.tool-call.active .tool-call-content {{
display: block;
}}
/* Reasoning */
.reasoning {{
margin-top: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--reasoning-border);
background-color: var(--reasoning-bg);
overflow: hidden;
}}
.reasoning-header {{
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-weight: 600;
}}
.reasoning-header:hover {{
background-color: rgba(0,0,0,0.02);
}}
.reasoning-header svg.chevron {{
transition: transform 0.2s ease;
color: var(--secondary-text);
}}
.reasoning.active svg.chevron {{
transform: rotate(90deg);
}}
.reasoning-content {{
display: none;
padding: 0 1rem 1rem 1rem;
font-size: 0.925rem;
color: var(--secondary-text);
border-top: 1px solid var(--reasoning-border);
padding-top: 1rem;
}}
.reasoning.active .reasoning-content {{
display: block;
}}
/* System Prompt Section */
.system-prompt-section {{
margin-top: 2rem;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
background-color: var(--user-bg);
overflow: hidden;
text-align: left;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}}
.system-prompt-header {{
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-weight: 600;
color: var(--secondary-text);
}}
.system-prompt-header:hover {{
background-color: rgba(0,0,0,0.02);
}}
.system-prompt-header svg.chevron {{
transition: transform 0.2s ease;
}}
.system-prompt-section.active svg.chevron {{
transform: rotate(90deg);
}}
.system-prompt-content {{
display: none;
padding: 0 1.25rem 1.25rem 1.25rem;
font-size: 0.9rem;
border-top: 1px solid var(--border-color);
padding-top: 1rem;
}}
.system-prompt-section.active .system-prompt-content {{
display: block;
}}
footer {{
margin-top: 5rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
text-align: center;
font-size: 0.875rem;
color: var(--secondary-text);
}}
/* Animation */
.fade-in {{
animation: fadeIn 0.4s ease-out backwards;
}}
@keyframes fadeIn {{
from {{ opacity: 0; transform: translateY(10px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
/* Utilities */
.hidden {{ display: none !important; }}
</style>
</head>
<body>
<div class="layout {layout_class}">
{sidebar_html}
<div class="main-content">
{sessions_html}
<footer>
Built with ☤ Hermes Agent • Generated on {generated_at}
</footer>
</div>
</div>
<script>
// Session Switching
function showSession(id) {{
// Update Sidebar
document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active'));
const activeItem = document.querySelector(`.session-item[data-id="${{id}}"]`);
if (activeItem) activeItem.classList.add('active');
// Update View
document.querySelectorAll('.session-view').forEach(el => {{
el.classList.remove('active');
}});
const activeView = document.getElementById(`view-${{id}}`);
if (activeView) activeView.classList.add('active');
// Store in URL hash
window.location.hash = id;
}}
// Search Filter
const searchInput = document.getElementById('session-search');
if (searchInput) {{
searchInput.addEventListener('input', (e) => {{
const term = e.target.value.toLowerCase();
document.querySelectorAll('.session-item').forEach(item => {{
const text = item.innerText.toLowerCase();
if (text.includes(term)) {{
item.classList.remove('hidden');
}} else {{
item.classList.add('hidden');
}}
}});
}});
}}
// Card Toggles
document.addEventListener('click', function(e) {{
const header = e.target.closest('.message-header, .tool-call-header, .reasoning-header, .system-prompt-header');
if (header) {{
header.parentElement.classList.toggle('active');
}}
}});
// Initialization
window.addEventListener('load', () => {{
const hash = window.location.hash.slice(1);
if (hash) {{
showSession(hash);
}} else {{
// Show first session by default if none selected
const first = document.querySelector('.session-item');
if (first) showSession(first.getAttribute('data-id'));
}}
}});
// Intersection Observer for scroll animations
if ('IntersectionObserver' in window) {{
var observer = new IntersectionObserver(function(entries) {{
entries.forEach(function(entry) {{
if (entry.isIntersecting) {{
entry.target.classList.add('fade-in');
observer.unobserve(entry.target);
}}
}});
}}, {{ threshold: 0.1 }});
document.querySelectorAll('.message').forEach(function(m) {{ observer.observe(m); }});
}}
</script>
</body>
</html>
"""
def _escape_html(text: str) -> str:
if not isinstance(text, str):
text = str(text)
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&#39;")
def _format_timestamp(ts: float) -> str:
if not ts: return "N/A"
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
def _generate_messages_html(messages: List[Dict[str, Any]]) -> str:
html_list = []
for i, msg in enumerate(messages):
role = msg.get("role", "unknown")
# Skip internal metadata messages
if role == "session_meta":
continue
content = msg.get("content") or ""
timestamp = _format_timestamp(msg.get("timestamp", 0))
# Icon selection
role_icon = ICON_TERMINAL
if role == "user":
role_icon = ICON_USER
elif role == "assistant":
role_icon = ICON_BOT
elif role == "system":
role_icon = ICON_SHIELD
# Handle multimodal or complex content
if isinstance(content, list):
content_parts = []
for part in content:
if isinstance(part, dict):
if part.get("type") == "text":
content_parts.append(part.get("text", ""))
elif part.get("type") == "image_url":
content_parts.append("[Image Attachment]")
else:
content_parts.append(str(part))
content = "\n".join(content_parts)
# Build message HTML
msg_class = f"message message-{role} active"
# Delay animation for initial items
delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else ""
chevron_html = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')
html = f'<div class="{msg_class}"{delay_style}>'
html += f' <div class="message-header">'
html += f' <div class="role-badge">{chevron_html} {role_icon} {role}</div>'
html += f' <div class="timestamp">{timestamp}</div>'
html += ' </div>'
html += ' <div class="message-body">'
# Tool Calls
tool_calls = msg.get("tool_calls")
if tool_calls:
for tc in tool_calls:
fn_name = tc.get("function", {}).get("name", "unknown")
args = tc.get("function", {}).get("arguments", "{}")
html += f'''
<div class="tool-call">
<div class="tool-call-header">
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
{ICON_WRENCH} Tool Call: {fn_name}
</div>
<div class="tool-call-content">
<pre><code>{_escape_html(args)}</code></pre>
</div>
</div>
'''
# Content
if content:
if role == "tool":
html += f' <div class="content"><pre><code>{_escape_html(content)}</code></pre></div>'
else:
html += f' <div class="content">{_escape_html(content)}</div>'
# Reasoning
reasoning = msg.get("reasoning") or msg.get("reasoning_content")
if reasoning:
html += f'''
<div class="reasoning">
<div class="reasoning-header">
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
{ICON_SPARKLES} Reasoning
</div>
<div class="reasoning-content">
<div class="content">{_escape_html(reasoning)}</div>
</div>
</div>
'''
html += ' </div>'
html += '</div>'
html_list.append(html)
return "\n".join(html_list)
def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
if not sessions:
return "<html><body><h1>No sessions to export.</h1></body></html>"
is_multi = len(sessions) > 1
generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Sidebar
sidebar_html = ""
if is_multi:
sidebar_items = []
for s in sessions:
sid = s.get("id", "N/A")
title = s.get("title") or s.get("preview") or "Untitled Session"
if len(title) > 50: title = title[:47] + "..."
date = _format_timestamp(s.get("started_at", 0)).split(" ")[0]
item = f'''
<a class="session-item" data-id="{sid}" onclick="showSession('{sid}')">
<div class="session-item-title">{_escape_html(title)}</div>
<div class="session-item-meta">
<span>{sid[:8]}</span>
<span>{date}</span>
</div>
</a>
'''
sidebar_items.append(item)
sidebar_html = f'''
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
{ICON_HERMES} Hermes History
</div>
<div class="search-container">
{ICON_SEARCH}
<input type="text" id="session-search" placeholder="Search sessions...">
</div>
</div>
<div class="session-list">
{"".join(sidebar_items)}
</div>
</aside>
'''
# Main Content
sessions_html_list = []
for s in sessions:
sid = s.get("id", "N/A")
title = s.get("title") or "Hermes Session"
model = s.get("model", "Unknown")
started_at = _format_timestamp(s.get("started_at", 0))
messages = s.get("messages", [])
messages_html = _generate_messages_html(messages)
view_class = "session-view"
if not is_multi: view_class += " active"
session_view_id = f"view-{sid}"
system_prompt = s.get("system_prompt")
system_html = ""
if system_prompt:
system_html = f'''
<div class="system-prompt-section active">
<div class="system-prompt-header">
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
{ICON_SHIELD} System Prompt (Persona)
</div>
<div class="system-prompt-content">
<div class="content">{_escape_html(system_prompt)}</div>
</div>
</div>
'''
session_html = f'''
<div class="{view_class}" id="{session_view_id}">
<header class="fade-in">
<h1>{_escape_html(title)}</h1>
<div class="meta">
<div class="meta-item"><strong>ID:</strong> {sid}</div>
<div class="meta-item"><strong>Model:</strong> {_escape_html(model)}</div>
<div class="meta-item"><strong>Started:</strong> {started_at}</div>
</div>
{system_html}
</header>
<main>
{messages_html}
</main>
</div>
'''
sessions_html_list.append(session_html)
return HTML_TEMPLATE.format(
page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title", "Hermes Session")),
sidebar_html=sidebar_html,
sessions_html="\n".join(sessions_html_list),
main_margin="var(--sidebar-width)" if is_multi else "0",
layout_class="layout-multi" if is_multi else "layout-single",
generated_at=generated_at
)
def generate_html_export(session_data: Dict[str, Any]) -> str:
"""Legacy wrapper for single session export."""
return generate_multi_session_html_export([session_data])
+279
View File
@@ -0,0 +1,279 @@
"""Markdown/QMD export helpers for Hermes sessions.
This module is intentionally filesystem-only: it formats already-exported
SessionDB dictionaries and writes them to user-selected export directories. It
must not mutate state.db or call delete/prune/archive APIs.
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
EXPORTER_VERSION = "hermes sessions export (md/qmd) v1"
_SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`")
def _iso_timestamp(value: Any) -> str:
if value is None or value == "":
return ""
try:
ts = float(value)
except (TypeError, ValueError):
return str(value)
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _frontmatter_value(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)) and not isinstance(value, bool):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, list):
return json.dumps(value, ensure_ascii=False)
return json.dumps(str(value), ensure_ascii=False)
def _frontmatter_line(key: str, value: Any) -> str:
return f"{key}: {_frontmatter_value(value)}"
def _message_heading(message: dict[str, Any]) -> str:
role = str(message.get("role") or "message")
label = role.capitalize()
name = message.get("name") or message.get("tool_name")
if role == "tool" and name:
label = f"Tool — {name}"
timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp"))
return f"### {label}{' — ' + timestamp if timestamp else ''}"
def _render_content(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content.rstrip()
return "```json\n" + json.dumps(content, ensure_ascii=False, indent=2) + "\n```"
def _render_tool_calls(tool_calls: Any) -> str:
if not tool_calls:
return ""
return "\n\n## Tool calls\n\n```json\n" + json.dumps(tool_calls, ensure_ascii=False, indent=2) + "\n```"
def _session_id(session: dict[str, Any]) -> str:
return str(session.get("id") or session.get("session_id") or "unknown-session")
def _segments(session: dict[str, Any]) -> list[dict[str, Any]]:
segments = session.get("segments")
if isinstance(segments, list) and segments:
return [s for s in segments if isinstance(s, dict)]
return [session]
def _message_count(session: dict[str, Any]) -> int:
return sum(len(seg.get("messages") or []) for seg in _segments(session))
def _render_messages(session: dict[str, Any]) -> str:
parts: list[str] = ["## Messages\n"]
segments = _segments(session)
total_messages = _message_count(session)
if total_messages == 0:
parts.append("_No messages in this session._\n")
return "\n".join(parts).rstrip() + "\n"
multi_segment = len(segments) > 1
for segment in segments:
if multi_segment:
parts.append(f"## Compression segment: {_session_id(segment)}\n")
for message in list(segment.get("messages") or []):
parts.append(_message_heading(message) + "\n")
rendered_content = _render_content(message.get("content"))
if rendered_content:
parts.append(rendered_content + "\n")
tool_calls = _render_tool_calls(message.get("tool_calls"))
if tool_calls:
parts.append(tool_calls + "\n")
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str:
session_id = _session_id(session)
title = session.get("title") or session_id
provider = session.get("billing_provider") or session.get("provider")
started_at = _iso_timestamp(session.get("started_at") or session.get("created_at"))
last_active = _iso_timestamp(session.get("last_active") or session.get("updated_at"))
ended_at = _iso_timestamp(session.get("ended_at"))
exported_iso = _iso_timestamp(exported_at)
message_count = _message_count(session)
frontmatter = [
"---",
_frontmatter_line("session_id", session_id),
_frontmatter_line("title", session.get("title")),
_frontmatter_line("source", session.get("source")),
_frontmatter_line("created_at", started_at),
_frontmatter_line("updated_at", last_active),
_frontmatter_line("ended_at", ended_at),
_frontmatter_line("model", session.get("model")),
_frontmatter_line("provider", provider),
_frontmatter_line("cwd", session.get("cwd")),
_frontmatter_line("archived", bool(session.get("archived"))),
_frontmatter_line("message_count", message_count),
_frontmatter_line("tool_call_count", session.get("tool_call_count") or 0),
]
if session.get("lineage_session_ids"):
frontmatter.append(_frontmatter_line("lineage_session_ids", session.get("lineage_session_ids")))
frontmatter.extend([
_frontmatter_line("format", fmt),
_frontmatter_line("exported_at", exported_iso),
_frontmatter_line("exporter", EXPORTER_VERSION),
"---",
"",
])
parts = ["\n".join(frontmatter), f"# {title}\n"]
parts.append(f"Session ID: `{session_id}`\n")
if session.get("source"):
parts.append(f"Source: `{session.get('source')}`\n")
if session.get("cwd"):
parts.append(f"Working directory: `{session.get('cwd')}`\n")
parts.append(_render_messages(session))
parts.append("## Export verification\n")
parts.append(f"- Session id: `{session_id}`")
parts.append(f"- Exported messages: `{message_count}`")
parts.append(f"- Source DB message count at export: `{session.get('message_count', message_count)}`")
parts.append(f"- Exported at: `{exported_iso}`")
parts.append("- SHA256 of exported body: `__SHA256_PLACEHOLDER__`")
return "\n".join(parts).rstrip() + "\n"
def _body_for_digest(text: str) -> str:
return _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text)
def render_session_markdown(
session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True
) -> str:
"""Render a SessionDB export dictionary as Markdown/QMD text."""
if fmt not in {"md", "qmd"}:
raise ValueError("fmt must be 'md' or 'qmd'")
exported_at = time.time()
body = _export_body_without_hash(session, fmt=fmt, exported_at=exported_at)
digest_body = body.replace("`__SHA256_PLACEHOLDER__`", "`pending`")
digest = hashlib.sha256(digest_body.encode("utf-8")).hexdigest()
if include_verification:
return body.replace("__SHA256_PLACEHOLDER__", digest)
before_verification = body.split("\n## Export verification\n", 1)[0].rstrip() + "\n"
return before_verification
def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str:
"""Return a deterministic, path-safe filename for a session export."""
if fmt not in {"md", "qmd"}:
raise ValueError("fmt must be 'md' or 'qmd'")
session_id = _session_id(session)
title = str(session.get("title") or "session")
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower()
if not slug:
slug = "session"
slug = slug[:60]
return f"{session_id}-{slug}.{fmt}"
def file_sha256(path: Path | str) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]:
p = Path(path)
if not p.exists():
return False, "file missing"
text = p.read_text(encoding="utf-8")
match = _SHA_LINE_RE.search(text)
if not match:
return False, "sha256 marker missing"
actual = hashlib.sha256(_body_for_digest(text).encode("utf-8")).hexdigest()
if actual != match.group(1):
return False, "sha256 mismatch"
expected_count = _message_count(session)
if f"- Exported messages: `{expected_count}`" not in text:
return False, "message count mismatch"
if f"- Session id: `{_session_id(session)}`" not in text:
return False, "session id mismatch"
return True, "ok"
def redact_session_data(session: dict[str, Any]) -> dict[str, Any]:
"""Return a deep copy of a session export dict with secrets redacted.
Runs every message's content and tool-call arguments through the
force-mode redaction pass (``agent.redact.redact_sensitive_text``), so
API keys, tokens, and credentials that appeared in tool output never
land in plaintext export files. Force mode ignores the user's global
``security.redact_secrets`` preference — an explicit ``--redact`` export
must never emit raw secrets.
"""
from agent.redact import redact_sensitive_text
def _clean(value: Any) -> Any:
if isinstance(value, str):
return redact_sensitive_text(value, force=True)
if isinstance(value, list):
return [_clean(v) for v in value]
if isinstance(value, dict):
return {k: _clean(v) for k, v in value.items()}
return value
redacted = dict(session)
for key in ("messages", "segments"):
if key in redacted and redacted[key] is not None:
redacted[key] = _clean(redacted[key])
return redacted
def write_session_markdown(
session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
) -> Path:
"""Write a Markdown/QMD export file and return its path.
Raises FileExistsError when the destination exists and force=False.
"""
out_dir = Path(output_dir).expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / safe_session_filename(session, fmt=fmt)
if path.exists() and not force:
raise FileExistsError(str(path))
path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8")
return path
def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path:
out_dir = Path(output_dir).expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
export_path = Path(path)
entry = {
"session_id": _session_id(session),
"lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)],
"path": str(export_path),
"format": fmt,
"message_count": _message_count(session),
"sha256": file_sha256(export_path),
"exported_at": time.time(),
}
manifest = out_dir / "manifest.jsonl"
with manifest.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
return manifest
+7
View File
@@ -55,6 +55,13 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
"message. Zero LLM cost. Requires --deliver to be a real target "
"(not 'log').",
)
wh_sub.add_argument(
"--script",
default="",
help="Filter/transform script under ~/.hermes/scripts/. The route "
"payload is passed as JSON on stdin; empty stdout, [SILENT], or a "
"nonzero exit code ignores the webhook.",
)
webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"
+33
View File
@@ -1612,6 +1612,28 @@ def enabled_mcp_server_names(config: dict) -> Set[str]:
}
def _exempt_explicit_platform_native(
default_off: Set[str], platform: str, *, explicitly_configured: bool
) -> None:
"""Let platform-native default-off toolsets through on explicit config.
Toolsets that are both in ``_DEFAULT_OFF_TOOLSETS`` and restricted to
``platform`` via ``_TOOLSET_PLATFORM_RESTRICTIONS`` (currently
``discord``/``discord_admin`` on the discord platform) are the platform's
own native tools. They are kept off for *unconfigured* platforms (security
opt-in), but once a user explicitly saves a toolset list for the platform
the composite they chose (e.g. ``hermes-discord``, which contains those
tools) is an opt-in — stripping them silently defeats the explicit
configuration (#35527). Mutates ``default_off`` in place.
"""
if not explicitly_configured:
return
for ts in list(default_off):
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts)
if allowed is not None and platform in allowed:
default_off.discard(ts)
def _get_platform_tools(
config: dict,
platform: str,
@@ -1623,6 +1645,11 @@ def _get_platform_tools(
platform_toolsets = config.get("platform_toolsets") or {}
toolset_names = platform_toolsets.get(platform)
# Track whether the user explicitly saved a toolset list for this platform
# (vs. falling back to the platform default). An explicit composite (e.g.
# ``hermes-discord``) is an opt-in to the platform's native default-off
# toolsets — see _exempt_explicit_platform_native (#35527).
explicitly_configured = isinstance(toolset_names, list)
if toolset_names is None or not isinstance(toolset_names, list):
plat_info = PLATFORMS.get(platform)
@@ -1686,6 +1713,9 @@ def _get_platform_tools(
default_off.remove(platform)
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
default_off.remove("homeassistant")
_exempt_explicit_platform_native(
default_off, platform, explicitly_configured=explicitly_configured
)
expanded -= default_off
enabled_toolsets |= expanded
@@ -1748,6 +1778,9 @@ def _get_platform_tools(
# strip the entry we just added.
if x_search_auto_enabled and "x_search" in default_off:
default_off.remove("x_search")
_exempt_explicit_platform_native(
default_off, platform, explicitly_configured=explicitly_configured
)
enabled_toolsets -= default_off
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
+1730 -312
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -189,6 +189,10 @@ def _cmd_subscribe(args):
return
route["deliver_only"] = True
script = getattr(args, "script", "") or ""
if script.strip():
route["script"] = script.strip()
if args.deliver_chat_id:
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
@@ -212,6 +216,8 @@ def _cmd_subscribe(args):
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
label = "Message" if route.get("deliver_only") else "Prompt"
print(f" {label}: {prompt_preview}")
if route.get("script"):
print(f" Script: {route['script']}")
print("\n Configure your service to POST to the URL above.")
print(" Use the secret for HMAC-SHA256 signature validation.")
print(" The gateway must be running to receive events (hermes gateway run).\n")
@@ -238,6 +244,8 @@ def _cmd_list(args):
print(f" URL: {base_url}/webhooks/{name}")
print(f" Events: {events}")
print(f" Deliver: {deliver}")
if route.get("script"):
print(f" Script: {route['script']}")
print()
+78
View File
@@ -4958,6 +4958,64 @@ class SessionDB:
# Export and cleanup
# =========================================================================
def _is_branch_child_row(self, session: Dict[str, Any]) -> bool:
raw = session.get("model_config")
if not raw:
return False
try:
cfg = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, json.JSONDecodeError):
return False
return isinstance(cfg, dict) and cfg.get("_branched_from") is not None
def _is_compression_child_row(self, child: Dict[str, Any]) -> bool:
parent_id = child.get("parent_session_id")
if not parent_id or self._is_branch_child_row(child):
return False
parent = self.get_session(parent_id)
return bool(parent and parent.get("end_reason") == "compression")
def get_compression_lineage(self, session_id: str) -> List[str]:
"""Return compression ancestors through tip in chronological order."""
session = self.get_session(session_id)
if not session or self._is_branch_child_row(session):
return [session_id] if session else []
root = session
while self._is_compression_child_row(root):
parent = self.get_session(root["parent_session_id"])
if not parent:
break
root = parent
lineage = [root["id"]]
current = root
while current.get("end_reason") == "compression":
with self._lock:
rows = self._conn.execute(
"""
SELECT * FROM sessions
WHERE parent_session_id = ?
ORDER BY started_at ASC
""",
(current["id"],),
).fetchall()
next_child = None
for row in rows:
candidate = dict(row)
if not self._is_branch_child_row(candidate):
next_child = candidate
break
if not next_child:
break
lineage.append(next_child["id"])
current = next_child
if current["id"] == session_id:
# Continue to include later compression tips only when the
# requested session itself was compacted.
continue
return lineage if session_id in lineage else [session_id]
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a single session with all its messages as a dict."""
session = self.get_session(session_id)
@@ -4966,6 +5024,26 @@ class SessionDB:
messages = self.get_messages(session_id)
return {**session, "messages": messages}
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a compression lineage as one logical session dict."""
lineage_ids = self.get_compression_lineage(session_id)
if not lineage_ids:
return None
segments = []
for sid in lineage_ids:
segment = self.export_session(sid)
if segment:
segments.append(segment)
if not segments:
return None
base = dict(segments[-1])
total_messages = sum(len(seg.get("messages") or []) for seg in segments)
base["segments"] = segments
base["lineage_session_ids"] = [seg["id"] for seg in segments]
base["message_count"] = total_messages
base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])]
return base
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
"""
Export all sessions (with messages) as a list of dicts.
+10 -9
View File
@@ -1161,21 +1161,22 @@ def handle_function_call(
if function_name in _AGENT_LOOP_TOOLS:
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
# Check plugin hooks for a block directive (unless caller already
# checked — e.g. run_agent._invoke_tool passes skip=True to
# Check plugin hooks for a block/approve directive (unless caller
# already checked — e.g. run_agent._invoke_tool passes skip=True to
# avoid double-firing the hook).
#
# Single-fire contract: pre_tool_call fires exactly once per tool
# execution. get_pre_tool_call_block_message() internally calls
# invoke_hook("pre_tool_call", ...) and returns the first block
# directive (if any), so observer plugins see the hook on that same
# pass. When skip=True, the caller already fired it — do nothing
# here.
# execution. resolve_pre_tool_block() internally calls
# invoke_hook("pre_tool_call", ...) once and returns the block message
# for a `block` directive OR for an `approve` directive whose human
# gate denied/timed-out/errored (fail-closed). Observer plugins see
# the hook on that same pass. When skip=True, the caller already
# fired it — do nothing here.
if not skip_pre_tool_call_hook:
block_message: Optional[str] = None
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=task_id or "",
+8 -1
View File
@@ -28,13 +28,20 @@
packages =
with pkgs;
[
(pkgs.runCommand "hermes" { } ''
mkdir -p $out/bin
install -Dm755 ${../hermes} $out/bin/hermes
'')
uv
]
++ self'.packages.default.passthru.devDeps;
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
# for the devshell to pick up the src
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
echo "Ready. Run 'hermes' to start."
'';
};
+29 -25
View File
@@ -44,7 +44,7 @@ let
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesVenv = mkHermesVenv extraDependencyGroups;
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix nodejs;
@@ -200,33 +200,37 @@ stdenv.mkDerivation (finalAttrs: {
runHook postInstall
'';
passthru = {
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
passthru =
let
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
in
{
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
};
devShellHook = ''
export HERMES_PYTHON=${devPython}/bin/python3
'';
devDeps = runtimeDeps ++ [ devPython ];
};
devShellHook = ''
export HERMES_PYTHON=${hermesVenv}/bin/python3
'';
devDeps = runtimeDeps ++ [ (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])) ];
};
meta = with lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
+84 -47
View File
@@ -27,7 +27,8 @@ let
dependency-groups = { };
};
mkPrebuiltOverride = final: from: dependencies:
mkPrebuiltOverride =
final: from: dependencies:
hacks.nixpkgsPrebuilt {
inherit from;
prev = {
@@ -38,64 +39,100 @@ let
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
# and no pyproject.toml, so setuptools isn't declared as a build dep.
buildSystemOverrides = final: prev: builtins.mapAttrs
(name: _: prev.${name}.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
}))
(lib.genAttrs [
"alibabacloud-credentials-api"
"alibabacloud-endpoint-util"
"alibabacloud-gateway-dingtalk"
"alibabacloud-gateway-spi"
"alibabacloud-tea"
] (_: null));
buildSystemOverrides =
final: prev:
builtins.mapAttrs
(
name: _:
prev.${name}.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
})
)
(
lib.genAttrs [
"alibabacloud-credentials-api"
"alibabacloud-endpoint-util"
"alibabacloud-gateway-dingtalk"
"alibabacloud-gateway-spi"
"alibabacloud-tea"
] (_: null)
);
pythonPackageOverrides = final: _prev:
if isAarch64Darwin then {
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
pythonPackageOverrides =
final: _prev:
if isAarch64Darwin then
{
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
av = mkPrebuiltOverride final python312.pkgs.av { };
av = mkPrebuiltOverride final python312.pkgs.av { };
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
onnxruntime = [ ];
tokenizers = [ ];
tqdm = [ ];
};
} else {};
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
onnxruntime = [ ];
tokenizers = [ ];
tqdm = [ ];
};
}
else
{ };
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python312;
}).overrideScope
(lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
buildSystemOverrides
pythonPackageOverrides
]);
(
lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
buildSystemOverrides
pythonPackageOverrides
]
);
editableOverlay = workspace.mkEditablePyprojectOverlay {
root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time
};
workspaceRoot = ./..;
editableSet = pythonSet.overrideScope (
lib.composeManyExtensions [
editableOverlay
(final: prev: {
hermes-agent = prev.hermes-agent.overrideAttrs (old: {
# point straight at the real source instead of the filtered nix store copy
src = workspaceRoot;
nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; };
});
})
]
);
in
pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = dependency-groups;
{
venv = pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = dependency-groups;
};
editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" {
hermes-agent = dependency-groups;
};
}
+39 -4
View File
@@ -25,14 +25,50 @@ Behavioral settings live in `$HERMES_HOME/mem0.json` (set them via `hermes memor
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process) |
| `host` | — | Self-hosted Mem0 server URL (the Docker dashboard). When set, connects over HTTP with `X-API-Key`. Don't combine with `mode: oss` |
| `user_id` | `hermes-user` | User identifier on Mem0 |
| `agent_id` | `hermes` | Agent identifier |
| `rerank` | `true` | Rerank search results for relevance (platform mode only) |
| `rerank` | `false` | Rerank search results for relevance (platform mode only) |
The plugin has three connection modes:
- **Platform** — Mem0's hosted cloud (`api.mem0.ai`). Set `MEM0_API_KEY`. (default)
- **Self-hosted dashboard** — a Mem0 server you run yourself via Docker. Set `host`. See below.
- **OSS** — run Mem0 in-process with your own LLM + vector store. Set `mode: oss`. See below.
## Self-Hosted Dashboard (Server) Mode
Connect the plugin to a standalone Mem0 server you run yourself — the Docker-shipped Mem0 dashboard/server with its own REST API. Unlike OSS mode (which runs `mem0ai` in-process with your own vector store), here the plugin just talks HTTP to your server.
1. Run the Mem0 server (FastAPI + pgvector) from its Docker image and note its URL and `ADMIN_API_KEY`.
2. Point the plugin at it — via the setup wizard:
```bash
hermes memory setup # select "mem0" → "Self-hosted server"
# Or non-interactive:
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key
```
or via env vars:
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```
or in `$HERMES_HOME/mem0.json`:
```json
{
"host": "http://localhost:8888",
"api_key": "your-admin-api-key"
}
```
3. Start a fresh Hermes session and call `mem0_search` — it connects to your server.
The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. `api_key` is optional — omit it only for servers running with `AUTH_DISABLED`.
> Setting `host` routes to the self-hosted server automatically. Don't set `mode: oss` — OSS takes precedence and ignores `host`.
## OSS (Self-Hosted) Mode
Run Mem0 locally with your own LLM, embedder, and vector store.
Run Mem0 locally with your own LLM, embedder, and vector store. This is the in-process SDK mode. To instead connect to a Mem0 server you run via Docker, see [Self-Hosted Dashboard (Server) Mode](#self-hosted-dashboard-server-mode) above.
### Interactive Setup
@@ -106,7 +142,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
| Tool | Description |
|------|-------------|
| `mem0_list` | List all stored memories (paginated) |
| `mem0_search` | Semantic search by meaning |
| `mem0_add` | Store a fact verbatim (no LLM extraction) |
| `mem0_update` | Update a memory's text by ID |
+53 -61
View File
@@ -9,10 +9,15 @@ Configuration
-------------
Secret (lives in $HERMES_HOME/.env or the environment):
MEM0_API_KEY — Mem0 Platform API key (required for platform mode)
MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the
plugin talks to that server directly over HTTP
(X-API-Key auth) instead of the cloud API.
Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory
setup`):
mode — Backend mode: "platform" (default) or "oss"
host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var).
When set, routes to the self-hosted HTTP backend.
user_id — Canonical user identifier. When set, it is applied
uniformly across every gateway (CLI, Telegram, Slack,
Discord, …) so the same human gets one merged memory
@@ -44,7 +49,7 @@ logger = logging.getLogger(__name__)
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
_BREAKER_THRESHOLD = 5
_BREAKER_COOLDOWN_SECS = 120
_PREFETCH_WAIT_SECS = 1.5
_PREFETCH_WAIT_SECS = 3
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
@@ -81,6 +86,7 @@ def _load_config() -> dict:
config = {
"mode": os.environ.get("MEM0_MODE", "platform"),
"api_key": os.environ.get("MEM0_API_KEY", ""),
"host": os.environ.get("MEM0_HOST", ""),
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
"oss": {},
}
@@ -107,41 +113,22 @@ def _load_config() -> dict:
# Tool schemas
# ---------------------------------------------------------------------------
LIST_SCHEMA = {
"name": "mem0_list",
"description": (
"List ALL stored memories about the user, unranked and paginated. "
"Use for a full overview/audit at conversation start, or to browse "
"everything when you don't have a specific query. For answering a "
"specific question, prefer mem0_search."
),
"parameters": {
"type": "object",
"properties": {
"page": {"type": "integer", "description": "Page number (default: 1)."},
"page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."},
},
"required": [],
},
}
SEARCH_SCHEMA = {
"name": "mem0_search",
"description": (
"Search the user's memories by meaning; returns facts ranked by "
"relevance. Use this BEFORE answering any question that may depend on "
"relevance. Use this before answering any question that may depend on "
"what you know about the user (preferences, facts, history, people, "
"projects, past decisions). For multi-part or multi-hop questions, "
"call it MULTIPLE times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough. Set "
"rerank=true for higher accuracy on important queries."
"call it several times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
},
"required": ["query"],
},
@@ -169,7 +156,7 @@ UPDATE_SCHEMA = {
"name": "mem0_update",
"description": (
"Replace the text of an existing memory by its ID (take the ID from a "
"mem0_search or mem0_list result). Use when a stored fact has changed "
"mem0_search result). Use when a stored fact has changed "
"or was wrong — correct it in place instead of adding a duplicate."
),
"parameters": {
@@ -185,7 +172,7 @@ UPDATE_SCHEMA = {
DELETE_SCHEMA = {
"name": "mem0_delete",
"description": (
"Delete a memory by its ID (take the ID from a mem0_search or mem0_list "
"Delete a memory by its ID (take the ID from a mem0_search "
"result). Use when a stored fact is obsolete or the user asks you to "
"forget it; prefer mem0_update if the fact merely changed."
),
@@ -214,8 +201,10 @@ class Mem0MemoryProvider(MemoryProvider):
self._backend = None
self._mode = "platform"
self._api_key = ""
self._host = ""
self._user_id = _DEFAULT_USER_ID
self._agent_id = "hermes"
self._rerank_default = False
self._channel = "cli" # gateway channel name (cli/telegram/discord/...)
self._sync_thread = None
self._prefetch_thread = None
@@ -239,7 +228,9 @@ class Mem0MemoryProvider(MemoryProvider):
mode = cfg.get("mode", "platform")
if mode == "oss":
return bool(cfg.get("oss", {}).get("vector_store"))
return bool(cfg.get("api_key"))
# Platform needs an api_key; self-hosted needs a host (api_key optional
# when the server runs with AUTH_DISABLED).
return bool(cfg.get("api_key") or cfg.get("host"))
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/mem0.json."""
@@ -262,9 +253,10 @@ class Mem0MemoryProvider(MemoryProvider):
api_key_required = mode != "oss"
return [
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
{"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"},
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
]
def post_setup(self, hermes_home: str, config: dict) -> None:
@@ -288,6 +280,9 @@ class Mem0MemoryProvider(MemoryProvider):
if self._mode == "oss":
from ._backend import OSSBackend
return OSSBackend(self._config.get("oss", {}))
if self._host:
from ._backend import SelfHostedBackend
return SelfHostedBackend(self._api_key, self._host)
from ._backend import PlatformBackend
return PlatformBackend(self._api_key)
except Exception as e:
@@ -342,6 +337,7 @@ class Mem0MemoryProvider(MemoryProvider):
self._config = _load_config()
self._mode = self._config.get("mode", "platform")
self._api_key = self._config.get("api_key", "")
self._host = self._config.get("host", "")
# Resolution order for user_id:
# 1. Operator-configured MEM0_USER_ID (env or $HERMES_HOME/mem0.json) —
# the canonical principal, applied across every gateway so the same
@@ -358,6 +354,14 @@ class Mem0MemoryProvider(MemoryProvider):
configured = None
self._user_id = configured or kwargs.get("user_id") or _DEFAULT_USER_ID
self._agent_id = self._config.get("agent_id", "hermes")
# Persisted rerank preference (setup wizard / mem0.json). Used as the
# DEFAULT for mem0_search when the model doesn't pass ``rerank``
# explicitly; per-call args still win. Platform-only feature — other
# backends accept-and-ignore the flag.
_rr = self._config.get("rerank", False)
self._rerank_default = (
_rr.lower() in ("true", "1", "yes") if isinstance(_rr, str) else bool(_rr)
)
self._channel = kwargs.get("platform") or "cli"
self._backend = self._create_backend()
if self._backend and not self._atexit_registered:
@@ -378,22 +382,32 @@ class Mem0MemoryProvider(MemoryProvider):
return {"channel": self._channel} if self._channel else {}
def system_prompt_block(self) -> str:
mode_label = "platform (cloud API)" if self._mode == "platform" else "OSS (self-hosted)"
rerank_note = " Rerank is available on search." if self._mode == "platform" else ""
# Mirror the precedence in _create_backend (oss > host > platform) so
# the label always names the backend that actually runs. Checking
# ``host`` first here would mislabel an ``oss``+``host`` config as
# self-hosted HTTP even though OSS wins the routing.
if self._mode == "oss":
mode_label = "OSS (self-hosted)"
elif self._host:
mode_label = "self-hosted (HTTP API)"
else:
mode_label = "platform (cloud API)"
# Rerank is a Mem0 Platform feature only.
rerank_note = " Rerank is available on search." if (self._mode == "platform" and not self._host) else ""
return (
"# Mem0 Memory\n"
f"Active. Mode: {mode_label}. User: {self._user_id}.\n"
"You have persistent memory of this user from past conversations. "
"ALWAYS call mem0_search before answering anything that could depend "
"You should call mem0_search before answering anything that could depend "
"on prior context (the user's preferences, facts, history, people, "
"projects, or earlier decisions) — do not rely on the chat window "
"alone, and do not assume you have no memory.\n"
"For multi-part or multi-hop questions, run SEVERAL searches with "
"For multi-part or multi-hop questions, run several searches with "
"different wording/angles and follow-up searches on what the first "
"results surface; one search is rarely enough. Keep searching until "
"you have every fact the question needs before you answer.\n"
"Tools: mem0_search to find memories, mem0_add to store facts, "
f"mem0_list for a full overview, mem0_update and mem0_delete to manage by ID.{rerank_note}"
f"mem0_update and mem0_delete to manage by ID.{rerank_note}"
)
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
@@ -426,7 +440,7 @@ class Mem0MemoryProvider(MemoryProvider):
body = ""
try:
results = backend.search(
query, filters=self._read_filters(), top_k=10, rerank=True,
query, filters=self._read_filters(), top_k=10, rerank=False,
)
lines = [r.get("memory", "") for r in (results or []) if r.get("memory")]
if lines:
@@ -497,7 +511,7 @@ class Mem0MemoryProvider(MemoryProvider):
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [LIST_SCHEMA, SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
return [SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if self._backend is None:
@@ -516,36 +530,13 @@ class Mem0MemoryProvider(MemoryProvider):
msg += f" Check that your {vs.get('provider', 'vector store')} is running."
return json.dumps({"error": msg})
if tool_name == "mem0_list":
try:
page = max(1, int(args.get("page", 1)))
page_size = min(max(1, int(args.get("page_size", 100))), 200)
response = self._backend.get_all(
filters=self._read_filters(), page=page, page_size=page_size,
)
self._record_success()
results = response.get("results", [])
if not results:
return json.dumps({"result": "No memories stored yet."})
items = [{"id": m.get("id"), "memory": m.get("memory", "")}
for m in results]
return json.dumps({
"results": items,
"count": response.get("count", len(items)),
"page": page, "page_size": page_size,
})
except Exception as e:
if not _is_client_error(e):
self._record_failure()
return tool_error(self._format_error("Failed to list memories", e))
elif tool_name == "mem0_search":
if tool_name == "mem0_search":
query = args.get("query", "")
if not query:
return tool_error("Missing required parameter: query")
try:
top_k = max(1, min(int(args.get("top_k", 10)), 50))
rerank_raw = args.get("rerank", True)
rerank_raw = args.get("rerank", getattr(self, "_rerank_default", False))
if isinstance(rerank_raw, str):
rerank = rerank_raw.lower() not in ("false", "0", "no")
else:
@@ -576,7 +567,8 @@ class Mem0MemoryProvider(MemoryProvider):
)
self._record_success()
event_id = result.get("event_id") if isinstance(result, dict) else None
msg = "Fact stored." if self._mode == "oss" else "Fact queued for storage."
# Cloud add is async (server-side extraction); OSS and self-hosted store synchronously.
msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage."
return json.dumps({"result": msg, "event_id": event_id})
except Exception as e:
self._record_failure()
+76 -21
View File
@@ -10,11 +10,7 @@ class Mem0Backend(ABC):
"""Unified interface over Platform (MemoryClient) and OSS (Memory) backends."""
@abstractmethod
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
...
@abstractmethod
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
...
@abstractmethod
@@ -57,16 +53,10 @@ class PlatformBackend(Mem0Backend):
from mem0 import MemoryClient
self._client = MemoryClient(api_key=api_key)
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank)
return _unwrap_results(response)
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
response = self._client.get_all(filters=filters, page=page, page_size=page_size)
results = response.get("results", []) if isinstance(response, dict) else response
count = response.get("count", len(results)) if isinstance(response, dict) else len(results)
return {"results": results, "count": count}
def add(
self,
messages: list,
@@ -90,6 +80,79 @@ class PlatformBackend(Mem0Backend):
return {"result": "Memory deleted.", "memory_id": memory_id}
class SelfHostedBackend(Mem0Backend):
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the
cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation
call in ``__init__`` that the self-hosted server does not expose (it would
404 before any real request). This client talks to that server directly,
using its actual contract: ``X-API-Key`` auth and the ``/memories`` /
``/search`` routes.
"""
def __init__(self, api_key: str, host: str, transport=None):
import httpx
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers
# Connect-level retries smooth over transient blips so a single
# dropped SYN doesn't count toward the provider failure breaker.
# ``transport`` is injectable for tests (httpx.MockTransport).
if transport is None:
transport = httpx.HTTPTransport(retries=2)
self._client = httpx.Client(
base_url=host.rstrip("/"), headers=headers, timeout=30.0,
transport=transport,
)
def _json(self, method: str, path: str, **kwargs) -> Any:
resp = self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json() if resp.content else {}
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
# rerank is a platform-only feature; the self-hosted /search ignores it.
body: dict[str, Any] = {"query": query, "top_k": top_k}
if filters:
body["filters"] = filters # user_id belongs in filters (top-level is deprecated)
return _unwrap_results(self._json("POST", "/search", json=body))
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
body: dict[str, Any] = {
"messages": messages,
"user_id": user_id,
"agent_id": agent_id,
"infer": infer,
}
if metadata:
body["metadata"] = metadata
return self._json("POST", "/memories", json=body)
def update(self, memory_id: str, text: str) -> dict:
self._json("PUT", f"/memories/{memory_id}", json={"text": text})
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._json("DELETE", f"/memories/{memory_id}")
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self) -> None:
try:
self._client.close()
except Exception:
pass
class OSSBackend(Mem0Backend):
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
@@ -189,18 +252,10 @@ class OSSBackend(Mem0Backend):
except Exception:
pass
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
response = self._memory.search(query, filters=filters, top_k=top_k)
return _unwrap_results(response)
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
response = self._memory.get_all(filters=filters)
all_results = _unwrap_results(response)
total = len(all_results)
start = (page - 1) * page_size
results = all_results[start : start + page_size]
return {"results": results, "count": total}
def add(
self,
messages: list,
+128 -5
View File
@@ -67,6 +67,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
flags: dict[str, str] = {
"mode": "",
"api_key": "",
"host": "",
"oss_llm": "openai",
"oss_llm_key": "",
"oss_llm_model": "",
@@ -90,6 +91,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
flag_map = {
"--mode": "mode",
"--api-key": "api_key",
"--host": "host",
"--oss-llm": "oss_llm",
"--oss-llm-key": "oss_llm_key",
"--oss-llm-model": "oss_llm_model",
@@ -230,7 +232,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
]
existing_config = {}
@@ -293,6 +295,24 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
return
provider_config["mode"] = "platform"
# Clear any stale self-hosted host: routing checks ``host`` before platform
# (see _create_backend), so leaving it would silently keep routing to the
# self-hosted server even though the user just chose platform mode. Set it
# to "" rather than pop() — save_config merges into the existing mem0.json
# (existing.update), so a popped key would survive; an empty value overwrites
# it and reads as falsy at routing time.
provider_config["host"] = ""
# The json-file clear above can't help when the host comes from the
# environment: _load_config() seeds ``host`` from MEM0_HOST, and the
# docs tell self-hosted users to put MEM0_HOST in ~/.hermes/.env. Warn
# so the user knows platform mode won't take effect until it's removed.
if os.environ.get("MEM0_HOST", "").strip():
print(
"\n ⚠ MEM0_HOST is set in your environment "
f"({os.environ['MEM0_HOST']}). It overrides platform mode — "
"remove it from ~/.hermes/.env (or unset it) or Hermes will keep "
"routing to the self-hosted server."
)
from hermes_cli.config import save_config
config["memory"]["provider"] = "mem0"
@@ -313,6 +333,102 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
print("\n Start a new session to activate.\n")
def _check_selfhosted_server(host: str) -> None:
"""Best-effort reachability check for a self-hosted Mem0 server (non-fatal)."""
import urllib.error
import urllib.request as _urlreq
try:
req = _urlreq.Request(f"{host.rstrip('/')}/docs", method="GET")
_urlreq.urlopen(req, timeout=5)
print(f" ✓ Mem0 server reachable at {host}")
except urllib.error.HTTPError:
# Any HTTP response (401/403/404) still means something is listening.
print(f" ✓ Mem0 server responding at {host}")
except Exception:
print(f" ⚠ Could not reach {host} — check the URL and that the server is running.")
def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""Self-hosted mode setup — point at an existing Mem0 dashboard server.
For users already running the Dockerized Mem0 FastAPI server: stores the
server URL (behavioral -> mem0.json) and an optional API key
(secret -> .env as MEM0_API_KEY).
"""
existing_config = {}
config_path = Path(hermes_home) / "mem0.json"
if config_path.exists():
try:
existing_config = json.loads(config_path.read_text())
except Exception:
pass
provider_config = dict(existing_config)
print("\n Configuring mem0 (self-hosted server):\n")
host = flags.get("host") or _prompt(
"Mem0 server URL (e.g. http://localhost:8888)",
default=provider_config.get("host") or None,
)
if not host:
print(" Error: a server URL is required for self-hosted mode.", file=sys.stderr)
return
host = host.rstrip("/")
env_writes: dict[str, str] = {}
if flags.get("api_key"):
env_writes["MEM0_API_KEY"] = flags["api_key"]
else:
existing_key = os.environ.get("MEM0_API_KEY", "")
if existing_key:
masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set"
val = _prompt(f"Server API key (current: {masked}, blank to keep)", secret=True)
else:
val = _prompt("Server API key (blank if AUTH_DISABLED)", secret=True)
if val:
env_writes["MEM0_API_KEY"] = val
user_id = flags.get("user_id") or _prompt(
"User identifier", default=provider_config.get("user_id") or "hermes-user"
)
agent_id = _prompt("Agent identifier", default=provider_config.get("agent_id") or "hermes")
if flags.get("dry_run"):
print(f"\n [dry-run] Would save config: host={host}, user_id={user_id}, agent_id={agent_id}")
if env_writes:
print(" [dry-run] Would write API key to .env")
_check_selfhosted_server(host)
print(" [dry-run] No files written.\n")
return
provider_config["mode"] = "platform" # routing: oss > host > platform; host wins
provider_config["host"] = host
provider_config["user_id"] = user_id
provider_config["agent_id"] = agent_id
from hermes_cli.config import save_config
config["memory"]["provider"] = "mem0"
save_config(config)
from plugins.memory.mem0 import Mem0MemoryProvider
provider = Mem0MemoryProvider()
provider.save_config(provider_config, hermes_home)
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
_check_selfhosted_server(host)
print("\n Memory provider: mem0 (self-hosted)")
print(f" Server: {host}")
print(" Activation saved to config.yaml")
print(" Provider config saved")
if env_writes:
print(" API key saved to .env")
print("\n Start a new session to activate.\n")
def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""OSS mode setup — build config from flags or interactive prompts.
@@ -828,10 +944,10 @@ def _check_min_dep_version() -> None:
def post_setup(hermes_home: str, config: dict) -> None:
"""Entry point called by hermes memory setup framework.
Only intercepts when OSS mode is requested (via --mode oss flag or
interactive picker). For platform mode, returns without action so the
framework's schema-based flow handles it (preserving the original
platform onboarding experience).
Routes on --mode (platform / selfhosted / oss); with no flag it shows an
interactive picker with all three modes. Platform keeps the framework's
original schema-based onboarding; selfhosted points at an existing Mem0
server; oss builds a local SDK config.
"""
_check_min_dep_version()
flags = parse_flags(sys.argv[1:])
@@ -841,6 +957,10 @@ def post_setup(hermes_home: str, config: dict) -> None:
_setup_oss(hermes_home, config, flags)
return
if flags["mode"] in ("selfhosted", "self-hosted"):
_setup_selfhosted(hermes_home, config, flags)
return
if flags["mode"] == "platform":
_setup_platform(hermes_home, config, flags)
return
@@ -848,10 +968,13 @@ def post_setup(hermes_home: str, config: dict) -> None:
# No --mode flag: show interactive picker
mode_items = [
("Platform", "Mem0 Cloud API (lightweight, just needs an API key)"),
("Self-hosted server", "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"),
("Open Source", "Run Mem0 locally (self-hosted LLM + vector store)"),
]
mode_idx = _curses_select(" Select mode", mode_items, 0)
if mode_idx == 1:
_setup_selfhosted(hermes_home, config, flags)
elif mode_idx == 2:
flags["_mode_from_flag"] = False
_setup_oss(hermes_home, config, flags)
else:
+3 -3
View File
@@ -1,5 +1,5 @@
name: mem0
version: 1.2.0
description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication."
version: 1.3.0
description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)."
pip_dependencies:
- mem0ai>=2.0.7,<3
- mem0ai>=2.0.10,<3
+149 -7
View File
@@ -754,6 +754,13 @@ _DISCORD_PROMPT_TIMEOUT_MIN = 30
_DISCORD_PROMPT_TIMEOUT_MAX = 900
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in {"true", "1", "yes", "on"}
def _read_discord_prompt_timeout() -> int:
"""Return the timeout (in seconds) for Discord button views.
@@ -893,6 +900,7 @@ class DiscordAdapter(BasePlatformAdapter):
# rate limit (~1 edit per stream tick for the rest of a long reply).
# Mirrors the Telegram #58563 fix. Entries are dropped on finalize.
self._last_overflow_preview: Dict[tuple, str] = {}
self._warned_fail_closed_default = False
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
"""Surface post-startup discord.py task exits to the gateway supervisor.
@@ -1153,6 +1161,7 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=_is_dm,
channel_ids=_msg_channel_ids,
):
self._warn_if_fail_closed_default()
return
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
@@ -1626,6 +1635,31 @@ class DiscordAdapter(BasePlatformAdapter):
return True
return False
@staticmethod
def _is_discord_unknown_interaction(exc: BaseException) -> bool:
"""True for Discord's expired interaction token error."""
code = getattr(exc, "code", None)
if code is None:
data = getattr(exc, "data", None)
if isinstance(data, dict):
code = data.get("code")
try:
code = int(code)
except (TypeError, ValueError):
code = None
status = getattr(exc, "status", None)
response = getattr(exc, "response", None)
if status is None and response is not None:
status = getattr(response, "status", None) or getattr(response, "status_code", None)
try:
status = int(status)
except (TypeError, ValueError):
status = None
message = str(exc).lower()
return code == 10062 or (status == 404 and "unknown interaction" in message)
def _command_sync_mutation_interval_seconds(self) -> float:
return _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS
@@ -3195,6 +3229,18 @@ class DiscordAdapter(BasePlatformAdapter):
return True
return bool(channel_ids & allowed)
def _is_pairing_approved_user(self, user_id: str) -> bool:
"""True when the Discord user has an explicit Hermes pairing grant."""
user_id = str(user_id or "").strip()
if not user_id:
return False
try:
from gateway.pairing import PairingStore
return bool(PairingStore().is_approved("discord", user_id))
except Exception:
return False
def _is_allowed_user(
self,
user_id: str,
@@ -3235,6 +3281,14 @@ class DiscordAdapter(BasePlatformAdapter):
allowed_roles = getattr(self, "_allowed_role_ids", set())
has_users = bool(allowed_users)
has_roles = bool(allowed_roles)
# Pairing is a first-class auth grant in the gateway auth union and in
# Discord component buttons. Honor it here too so normal guild/DM text
# messages do not get dropped at the adapter before the pairing-aware
# gateway layer can see them.
if self._is_pairing_approved_user(user_id):
return True
if not has_users and not has_roles:
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
return True
@@ -3303,6 +3357,28 @@ class DiscordAdapter(BasePlatformAdapter):
m_roles = getattr(m, "roles", None) or []
return any(getattr(r, "id", None) in allowed_roles for r in m_roles)
def _warn_if_fail_closed_default(self) -> None:
"""Log once when Discord is rejecting traffic with no allowlist set."""
if getattr(self, "_warned_fail_closed_default", False):
return
allowed_users = getattr(self, "_allowed_user_ids", set()) or set()
allowed_roles = getattr(self, "_allowed_role_ids", set()) or set()
if allowed_users or allowed_roles:
return
if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip():
return
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
return
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
return
self._warned_fail_closed_default = True
logger.warning(
"[%s] Discord messages are being denied because no allowlist is configured. "
"Set DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, or "
"DISCORD_ALLOWED_CHANNELS, or set DISCORD_ALLOW_ALL_USERS=true for open access.",
self.name,
)
# ── Slash command authorization ─────────────────────────────────────
# Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``)
# are a separate Discord interaction surface from regular messages and
@@ -3964,9 +4040,22 @@ class DiscordAdapter(BasePlatformAdapter):
if not await self._check_slash_authorization(interaction, command_text):
return
await interaction.response.defer(ephemeral=True)
deferred_response = False
try:
await interaction.response.defer(ephemeral=True)
deferred_response = True
except Exception as e:
if not self._is_discord_unknown_interaction(e):
raise
logger.warning(
"[Discord] slash %s: interaction expired before defer. "
"Executing command anyway, skipping interaction followup.",
command_text,
)
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
if not deferred_response:
return
try:
if followup_msg:
await interaction.edit_original_response(content=followup_msg)
@@ -4562,7 +4651,17 @@ class DiscordAdapter(BasePlatformAdapter):
"""Create a Discord thread from a slash command and start a session in it."""
if not await self._check_slash_authorization(interaction, "/thread"):
return
await interaction.response.defer(ephemeral=True)
deferred_response = False
try:
await interaction.response.defer(ephemeral=True)
deferred_response = True
except Exception as e:
if not self._is_discord_unknown_interaction(e):
raise
logger.warning(
"[Discord] /thread: interaction expired before defer. "
"Creating the thread anyway, skipping interaction followups.",
)
result = await self._create_thread(
interaction,
name=name,
@@ -4572,7 +4671,8 @@ class DiscordAdapter(BasePlatformAdapter):
if not result.get("success"):
error = result.get("error", "unknown error")
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
if deferred_response:
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
return
thread_id = result.get("thread_id")
@@ -4580,7 +4680,8 @@ class DiscordAdapter(BasePlatformAdapter):
# Tell the user where the thread is
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
if deferred_response:
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
# Track thread participation so follow-ups don't require @mention
if thread_id:
@@ -5452,6 +5553,20 @@ class DiscordAdapter(BasePlatformAdapter):
body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix
return f"{prefix}{body}{suffix}"
def _approval_mention_content(self) -> Optional[str]:
"""Return user mentions for approval prompts when explicitly enabled.
Gated on ``discord.approval_mentions`` in config.yaml (bridged to the
``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries
can be mentioned; default off avoids surprise pings.
"""
if not _env_bool("DISCORD_APPROVAL_MENTIONS", False):
return None
user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit())
if not user_ids:
return None
return " ".join(f"<@{uid}>" for uid in user_ids)
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
@@ -5491,6 +5606,9 @@ class DiscordAdapter(BasePlatformAdapter):
"Do you want Hermes to run this command?\n\n"
"**Requested command:**\n```bash\n"
)
mention_content = self._approval_mention_content()
if mention_content:
prompt_prefix = f"{mention_content}\n{prompt_prefix}"
prompt_tail = f"\n```\n**Reason:** {reason_display}"
truncated_suffix = "\n... [truncated]"
command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail))
@@ -5526,7 +5644,17 @@ class DiscordAdapter(BasePlatformAdapter):
admin_user_ids=admin_user_ids,
)
msg = await channel.send(content=content, embed=embed, view=view)
send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view}
if mention_content:
allowed_mentions_cls = getattr(discord, "AllowedMentions", None)
if allowed_mentions_cls is not None:
send_kwargs["allowed_mentions"] = allowed_mentions_cls(
users=True,
roles=False,
everyone=False,
replied_user=False,
)
msg = await channel.send(**send_kwargs)
view._message = msg # store for on_timeout expiration editing
return SendResult(success=True, message_id=str(msg.id))
@@ -8017,7 +8145,11 @@ def interactive_setup() -> None:
print_info("Discord: already configured")
if not prompt_yes_no("Reconfigure Discord?", False):
if not get_env_value("DISCORD_ALLOWED_USERS"):
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
print_info(
"⚠️ Discord has no user allowlist. With the fail-closed default, "
"messages are denied unless you configure allowed users, roles, "
"or channels, or set DISCORD_ALLOW_ALL_USERS=true."
)
if prompt_yes_no("Add allowed users now?", True):
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
allowed_users = prompt("Allowed user IDs (comma-separated)")
@@ -8050,7 +8182,11 @@ def interactive_setup() -> None:
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
print_success("Discord allowlist configured")
else:
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
print_info(
"⚠️ No allowlist set. Discord will deny messages until you set "
"DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, "
"or DISCORD_ALLOW_ALL_USERS=true for open access."
)
print()
print_info("📬 Home Channel: where Hermes delivers cron job results,")
@@ -8111,6 +8247,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
if isinstance(allowed_users_cfg, list):
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
approval_mentions_cfg = (
discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg
else platform_extra_cfg.get("approval_mentions")
)
if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"):
os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
+6 -5
View File
@@ -27,6 +27,7 @@ _IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, Optional, Any
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
from hermes_constants import (
find_node_executable,
get_hermes_dir,
@@ -490,19 +491,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# QR codes to its log file and never reaches status:connected,
# so every gateway restart paid the 30s timeout + queued WhatsApp
# for indefinite retries. Mark non-retryable so the user gets a
# clear "run hermes whatsapp" message instead of the watcher
# clear pairing message instead of the watcher
# silently hammering an unconfigured platform.
creds_path = self._session_path / "creds.json"
if not creds_path.exists():
logger.warning(
"[%s] WhatsApp is enabled but not paired (no creds.json at %s). "
"Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from "
"your .env to disable.",
"Pair from the dashboard or run `hermes whatsapp`; remove "
"WHATSAPP_ENABLED from your .env to disable.",
self.name, creds_path,
)
self._set_fatal_error(
"whatsapp_not_paired",
"WhatsApp enabled but not paired — run `hermes whatsapp` to pair.",
"WhatsApp enabled but not paired — pair from the dashboard or run `hermes whatsapp`.",
retryable=False,
)
return False
@@ -648,8 +649,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
],
stdout=bridge_log_fh,
stderr=bridge_log_fh,
start_new_session=True,
env=bridge_env,
**windows_detach_popen_kwargs(),
)
_write_bridge_pidfile(self._session_path, self._bridge_process.pid)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.18.0"
version = "0.18.2"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
+1 -1
View File
@@ -5337,7 +5337,7 @@ class AIAgent:
"google/gemini-2",
"google/gemma-4",
"qwen/qwen3",
"tencent/hy3-preview",
"tencent/hy3",
"xiaomi/",
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
+13
View File
@@ -45,6 +45,10 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"grace@weeb.onl": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)
"contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)
"164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)
"sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity)
"4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity)
"spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028)
"taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface)
@@ -90,6 +94,7 @@ AUTHOR_MAP = {
"aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
"ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
"sahibzada@fastino.ai": "sahibzada-allahyar", # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
"ronaldrj@gmail.com": "rarf", # PR #56966 salvage (desktop chat model picker: hide implicitly discovered providers unless explicitly configured)
"jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
"jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
"gumclaw@gumroad.com": "gumclaw", # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows)
@@ -223,6 +228,7 @@ AUTHOR_MAP = {
"victor@rocketfueldev.com": "victor-kyriazakos",
"87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44",
"joaomarcosdias444@gmail.com": "JoaoMarcos44",
"neoguyver@icloud.com": "neoguyverx", # PR #60526 salvage (fail-closed write syntax gate; #60525)
"286497132+srojk34@users.noreply.github.com": "srojk34",
"srojk34@users.noreply.github.com": "srojk34", # legacy prefix-less noreply (PR #50098 salvage; #38763)
"pinkiilqwq@users.noreply.github.com": "PINKIIILQWQ", # PR #45035 salvage (resume-to-tip; #38763)
@@ -291,6 +297,8 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"caztronics@yahoo.com": "doncazper",
"30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov",
"210088133+rungmc357@users.noreply.github.com": "rungmc357",
"florian.rutishauser@outlook.com": "flo1t",
"fanyang@microsoft.com": "fanyangCS",
@@ -838,6 +846,10 @@ AUTHOR_MAP = {
"maks.mir@yahoo.com": "say8hi",
"27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky",
"web3blind@users.noreply.github.com": "web3blind",
"264741654+web3blind@users.noreply.github.com": "web3blind",
# Sessions export format cluster (July 2026): HTML + prompt-only salvages
"840004959@qq.com": "simplast",
"catbearlove1@gmail.com": "catbearlove1-lang",
"julia@alexland.us": "alexg0bot",
"christian@scheid.tech": "scheidti",
# Moonshot schema anyOf+enum salvage (May 2026)
@@ -880,6 +892,7 @@ AUTHOR_MAP = {
"kshitijk4poor@gmail.com": "kshitijk4poor",
"1294707+Tosko4@users.noreply.github.com": "Tosko4",
"keira.voss94@gmail.com": "keiravoss94",
"vadim.veroslavov@mail.ru": "veradim", # PR #41201 salvage (Z.AI endpoint persist)
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
"fqsy1416@gmail.com": "EKKOLearnAI",
"octo-patch@github.com": "octo-patch",
+128 -35
View File
@@ -100,7 +100,9 @@ try {
.slice(0, 16);
} catch {}
const PAIR_ONLY = args.includes('--pair-only');
const PAIR_JSON = args.includes('--pair-json');
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
const WHATSAPP_DM_POLICY = String(process.env.WHATSAPP_DM_POLICY || 'open').trim().toLowerCase();
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
@@ -197,6 +199,23 @@ function normalizeWhatsAppId(value) {
return String(value).replace(':', '@');
}
function redactWhatsAppId(value) {
const raw = String(value || '').trim();
if (!raw) return '';
const [userPart, domainPart = ''] = raw.split('@', 2);
const bare = userPart.split(':', 1)[0];
const digits = bare.replace(/\D/g, '');
const suffix = digits ? digits.slice(-4) : bare.slice(-4);
return `${suffix ? `…${suffix}` : '…'}${domainPart ? `@${domainPart}` : ''}`;
}
function emitDebugEvent(payload) {
if (!WHATSAPP_DEBUG) return;
try {
console.log(JSON.stringify({ event: 'debug', ...payload }));
} catch {}
}
function getMessageContent(msg) {
const content = msg?.message || {};
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
@@ -360,6 +379,13 @@ function rememberSentId(id) {
let sock = null;
let connectionState = 'disconnected';
function emitPairEvent(event) {
if (!PAIR_JSON) return;
try {
console.log(JSON.stringify({ ts: Date.now(), ...event }));
} catch {}
}
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
@@ -387,9 +413,13 @@ async function startSocket() {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
if (PAIR_JSON) {
emitPairEvent({ event: 'qr', qr });
} else {
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
}
}
if (connection === 'close') {
@@ -397,22 +427,39 @@ async function startSocket() {
connectionState = 'disconnected';
if (reason === DisconnectReason.loggedOut) {
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
emitPairEvent({ event: 'error', error: 'logged_out', reason });
if (!PAIR_JSON) {
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
}
process.exit(1);
} else {
// 515 = restart requested (common after pairing). Always reconnect.
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
emitPairEvent({ event: 'disconnected', reason });
if (!PAIR_JSON) {
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
}
}
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
}
} else if (connection === 'open') {
connectionState = 'connected';
console.log('✅ WhatsApp connected!');
const connectedUser = sock?.user
? {
id: sock.user.id || null,
name: sock.user.name || sock.user.verifiedName || null,
}
: null;
emitPairEvent({ event: 'connected', user: connectedUser });
if (!PAIR_JSON) {
console.log('✅ WhatsApp connected!');
}
if (PAIR_ONLY) {
console.log('✅ Pairing complete. Credentials saved.');
if (!PAIR_JSON) {
console.log('✅ Pairing complete. Credentials saved.');
}
// Give Baileys a moment to flush creds, then exit cleanly
setTimeout(() => process.exit(0), 2000);
}
@@ -484,24 +531,29 @@ async function startSocket() {
if (!msg.message) continue;
const chatId = msg.key.remoteJid;
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({
event: 'upsert', type,
fromMe: !!msg.key.fromMe, chatId,
senderId: msg.key.participant || chatId,
messageKeys: Object.keys(msg.message || {}),
}));
} catch {}
}
const senderId = msg.key.participant || chatId;
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
emitDebugEvent({
stage: 'upsert',
type,
fromMe: !!msg.key.fromMe,
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
messageKeys: Object.keys(msg.message || {}),
});
// Handle fromMe messages based on mode
let fromOwner = false;
if (msg.key.fromMe) {
if (isGroup || chatId.includes('status')) continue;
if (isGroup || chatId.includes('status')) {
emitDebugEvent({
stage: 'ignored',
reason: isGroup ? 'from_me_group' : 'from_me_status',
chatId: redactWhatsAppId(chatId),
});
continue;
}
if (WHATSAPP_MODE === 'bot') {
// Bot mode: separate bot number. fromMe inbound is either
@@ -546,7 +598,22 @@ async function startSocket() {
const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
const chatNumber = chatId.replace(/@.*/, '');
const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
if (!isSelfChat) continue;
emitDebugEvent({
stage: 'self_chat_check',
matched: !!isSelfChat,
chatId: redactWhatsAppId(chatId),
accountId: redactWhatsAppId(sock.user?.id),
accountLid: redactWhatsAppId(sock.user?.lid),
});
if (!isSelfChat) {
emitDebugEvent({
stage: 'ignored',
reason: 'self_chat_mismatch',
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
});
continue;
}
}
}
@@ -567,7 +634,7 @@ async function startSocket() {
} catch {}
continue;
}
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
@@ -659,25 +726,39 @@ async function startSocket() {
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
if (WHATSAPP_DEBUG) {
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
emitDebugEvent({
stage: 'ignored',
reason: 'agent_echo',
chatId: redactWhatsAppId(chatId),
messageId: msg.key.id,
});
}
continue;
}
// Skip empty messages
if (!event.body && !event.hasMedia) {
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
} catch (err) {
console.error('Failed to log empty message event:', err);
}
}
emitDebugEvent({
stage: 'ignored',
reason: 'empty',
chatId: redactWhatsAppId(chatId),
messageKeys: Object.keys(msg.message || {}),
});
continue;
}
messageStore.remember(msg);
messageQueue.push(event);
emitDebugEvent({
stage: 'queued',
chatId: redactWhatsAppId(chatId),
senderId: redactWhatsAppId(senderId),
fromOwner: !!fromOwner,
bodyLength: event.body.length,
hasMedia: event.hasMedia,
mediaType: event.mediaType,
queueLength: messageQueue.length,
});
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
}
@@ -999,10 +1080,20 @@ app.get('/health', (req, res) => {
// Start
if (PAIR_ONLY) {
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
console.log('📱 WhatsApp pairing mode');
console.log(`📁 Session: ${SESSION_DIR}`);
console.log();
startSocket();
if (PAIR_JSON) {
emitPairEvent({ event: 'started', session: SESSION_DIR });
} else {
console.log('📱 WhatsApp pairing mode');
console.log(`📁 Session: ${SESSION_DIR}`);
console.log();
}
startSocket().catch((err) => {
emitPairEvent({ event: 'error', error: err?.message || String(err) });
if (!PAIR_JSON) {
console.error(err);
}
process.exit(1);
});
} else {
app.listen(PORT, '127.0.0.1', () => {
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
@@ -1011,6 +1102,8 @@ if (PAIR_ONLY) {
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
} else if (WHATSAPP_MODE === 'self-chat') {
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
} else if (WHATSAPP_MODE === 'bot' && WHATSAPP_DM_POLICY === 'pairing') {
console.log(`🤝 WHATSAPP_DM_POLICY=pairing — unknown DMs are forwarded for gateway pairing.`);
} else {
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
+295 -259
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@
"start": "node bridge.js"
},
"dependencies": {
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
"@whiskeysockets/baileys": "7.0.0-rc13",
"express": "^4.21.0",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
+90
View File
@@ -590,3 +590,93 @@ class TestAuxiliaryClientBedrockResolution:
_, model = resolve_provider_client("bedrock", None)
assert "haiku" in model.lower()
def test_bedrock_non_claude_model_uses_converse_client(self, monkeypatch):
"""Non-Claude Bedrock models (e.g. gpt-oss) must use Converse, not Anthropic SDK."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client") as mock_build:
from agent.auxiliary_client import (
BedrockAuxiliaryClient,
resolve_provider_client,
)
client, model = resolve_provider_client(
"bedrock", "openai.gpt-oss-20b-1:0"
)
mock_build.assert_not_called()
assert isinstance(client, BedrockAuxiliaryClient)
assert model == "openai.gpt-oss-20b-1:0"
def test_bedrock_claude_model_still_uses_anthropic_client(self, monkeypatch):
"""Claude Bedrock IDs should keep the Anthropic SDK auxiliary path."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
mock_anthropic_bedrock = MagicMock()
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
return_value=mock_anthropic_bedrock):
from agent.auxiliary_client import (
AnthropicAuxiliaryClient,
resolve_provider_client,
)
client, model = resolve_provider_client(
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert isinstance(client, AnthropicAuxiliaryClient)
assert "claude-sonnet" in model
def test_bedrock_non_claude_async_mode(self, monkeypatch):
"""Async mode for non-Claude Bedrock should return AsyncBedrockAuxiliaryClient."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client"):
from agent.auxiliary_client import (
AsyncBedrockAuxiliaryClient,
resolve_provider_client,
)
client, _ = resolve_provider_client(
"bedrock", "openai.gpt-oss-20b-1:0", async_mode=True
)
assert isinstance(client, AsyncBedrockAuxiliaryClient)
def test_bedrock_converse_shim_normalizes_string_stop(self, monkeypatch):
"""OpenAI callers may pass stop='STR'; Converse requires a list."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
from agent.auxiliary_client import BedrockAuxiliaryClient
client = BedrockAuxiliaryClient("us-east-1", "openai.gpt-oss-20b-1:0")
with patch("agent.bedrock_adapter.call_converse") as mock_converse:
client.chat.completions.create(
model="openai.gpt-oss-20b-1:0",
messages=[{"role": "user", "content": "hi"}],
stop="STOP",
)
assert mock_converse.call_args.kwargs["stop_sequences"] == ["STOP"]
def test_bedrock_converse_shim_stream_returns_complete_response(self, monkeypatch):
"""stream=True is not supported by the shim — a complete response comes
back and call_llm's streaming consumer downgrades gracefully."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
from agent.auxiliary_client import BedrockAuxiliaryClient
client = BedrockAuxiliaryClient("us-east-1", "openai.gpt-oss-20b-1:0")
sentinel = object()
with patch("agent.bedrock_adapter.call_converse", return_value=sentinel) as mock_converse:
resp = client.chat.completions.create(
model="openai.gpt-oss-20b-1:0",
messages=[{"role": "user", "content": "hi"}],
stream=True,
)
# Non-streaming call_converse is still used; the caller's
# got-final-object downgrade path handles the rest.
assert resp is sentinel
assert mock_converse.call_count == 1
+246
View File
@@ -0,0 +1,246 @@
"""System-prompt assembly for the desktop chat surface.
Pins the second half of the fix: the new ``PLATFORM_HINTS["desktop"]``
entry, the deletion of the standalone desktop-hint block from
``build_environment_hints()``, and the lookup-site extension that
appends the embedded-terminal-pane clarifier to the ``tui`` platform hint
when ``HERMES_DESKTOP_TERMINAL=1``.
These tests run against the real prompt builders (no mocks) because
cache-stability and byte-for-byte text contracts are what we are
verifying — mocking the resolver would hide exactly the class of bug
this test covers.
"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from agent.prompt_builder import PLATFORM_HINTS, build_environment_hints
from agent.system_prompt import (
_tui_embedded_pane_clarifier,
build_system_prompt_parts,
)
def _stable_prompt(agent):
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value=""),
):
return build_system_prompt_parts(agent)["stable"]
def _make_agent(platform="", **overrides):
base = dict(
load_soul_identity=False,
skip_context_files=False,
valid_tool_names=[],
_task_completion_guidance=False,
_tool_use_enforcement=False,
_environment_probe=False,
_kanban_worker_guidance="",
_memory_store=None,
_memory_manager=None,
_platform_hint_overrides={},
model="",
provider="",
pass_session_id=False,
session_id="",
)
base["platform"] = platform
base.update(overrides)
return SimpleNamespace(**base)
class TestDesktopHintEntry:
def test_desktop_key_exists(self):
"""The map must carry a "desktop" entry — without it the platform
hint lookup falls through to an empty string and the agent gets no
surface framing at all on the desktop chat surface."""
assert "desktop" in PLATFORM_HINTS
def test_desktop_hint_disambiguates_from_terminal(self):
"""The agent must be told it is in a graphical chat surface, NOT a
terminal. This is the line that kills the contradiction with the
old tui mis-tag."""
hint = PLATFORM_HINTS["desktop"]
lowered = hint.lower()
assert "desktop" in lowered
assert "not a terminal" in lowered
assert "graphical chat surface" in lowered
def test_desktop_hint_advertises_markdown(self):
"""The desktop renderer supports full GFM (verified via the
Streamdown pipeline in apps/desktop). The hint must steer the
agent toward markdown, not away from it like the cli/tui hints do."""
hint = PLATFORM_HINTS["desktop"]
assert "markdown" in hint.lower()
def test_desktop_hint_advertises_media_delivery(self):
"""The desktop chat intercepts MEDIA:/abs/path like telegram — images
inline, audio/video inline players, other files as download links.
Without this line the agent falls back to the cli/tui "state the
path in text" model, which is the wrong UX for the desktop surface."""
hint = PLATFORM_HINTS["desktop"]
assert "MEDIA:" in hint
def test_desktop_hint_advertises_inline_image_urls(self):
hint = PLATFORM_HINTS["desktop"]
assert "![alt](url)" in hint
def test_desktop_hint_does_not_inherit_tui_cron_local_only_block(self):
"""The desktop chat surface's cron delivery semantics differ from
the standalone TUI — desktop runs its own cron ticker in-process
(hermes_cli/web_server.py under HERMES_DESKTOP=1). We deliberately
do NOT parrot the tui "LOCAL-ONLY … no live-delivery channel" block
into the desktop hint, since partially-correct cron guidance is
exactly the bug class we are fixing. Cron guidance for desktop is
deferred to a follow-up issue."""
hint = PLATFORM_HINTS["desktop"]
assert "LOCAL-ONLY" not in hint
class TestDesktopHintBlockRemoved:
"""The standalone desktop-hint block that used to live in
``build_environment_hints()`` (lines ~1130-1144) was a band-aid for the
missing ``PLATFORM_HINTS["desktop"]`` entry. Once that entry exists, the
block is dead code that competes with the platform hint's claim of
what surface the agent is on. It must be gone."""
def test_build_environment_hints_has_no_runtime_surface_line(self, monkeypatch):
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
from agent.prompt_builder import _clear_backend_probe_cache
_clear_backend_probe_cache()
hints = build_environment_hints()
assert "Runtime surface:" not in hints
assert "desktop GUI app" not in hints
def test_build_environment_hints_has_no_embedded_pane_clarifier(self, monkeypatch):
"""The ⌥-drag / ⌘+L embedded-pane clarifier moves to the platform-hint
resolution site (system_prompt.py), not build_environment_hints()."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
from agent.prompt_builder import _clear_backend_probe_cache
_clear_backend_probe_cache()
hints = build_environment_hints()
assert "embedded terminal pane" not in hints
assert "Shift-drag" not in hints
class TestPlatformHintResolutionInStablePrompt:
"""End-to-end through ``build_system_prompt_parts`` — the platform tag on
the agent drives BOTH which PLATFORM_HINTS entry gets appended AND
whether the embedded-pane clarifier follows it. The desktop-hint block
that used to live in ``build_environment_hints()`` is gone."""
def test_desktop_platform_yields_desktop_hint_no_tui_framing(self, monkeypatch):
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
stable = _stable_prompt(_make_agent(platform="desktop"))
assert PLATFORM_HINTS["desktop"] in stable
assert "terminal UI" not in stable
assert "Runtime surface:" not in stable
assert "embedded terminal pane" not in stable
def test_standalone_tui_yields_plain_tui_hint_no_clarifier(self, monkeypatch):
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
stable = _stable_prompt(_make_agent(platform="tui"))
assert PLATFORM_HINTS["tui"] in stable
assert "embedded terminal pane" not in stable
def test_embedded_tui_yields_tui_hint_with_clarifier(self, monkeypatch):
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
stable = _stable_prompt(_make_agent(platform="tui"))
assert PLATFORM_HINTS["tui"] in stable
assert "embedded terminal pane" in stable
assert "Shift-drag" in stable or "Option-drag" in stable or "⌥" in stable
def test_embedded_clarifier_does_not_attach_to_desktop_platform(self, monkeypatch):
"""Critical regression: even when HERMES_DESKTOP_TERMINAL=1, a
desktop-tagged session must NOT get the embedded-pane clarifier —
the clarifier describes the *embedded terminal pane*, which a
desktop chat session is not."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
stable = _stable_prompt(_make_agent(platform="desktop"))
assert "embedded terminal pane" not in stable
class TestEmbeddedTuiPaneClarifier:
"""When ``HERMES_DESKTOP_TERMINAL=1``, a standalone ``hermes --tui`` is
running inside the desktop's embedded terminal pane. The user can
⌥-drag-select its output and ⌘/Ctrl+L to send it to the chat composer.
That clarifier must be appended to the ``tui`` platform hint at the
resolution site, NOT baked into the static ``PLATFORM_HINTS["tui"]``
string (which is shared with every standalone TUI session and must
stay byte-stable)."""
def test_tui_standalone_hint_byte_stable_without_env(self, monkeypatch):
"""Without HERMES_DESKTOP_TERMINAL, the clarifier is a no-op and the
resolved tui hint is exactly the static PLATFORM_HINTS["tui"]
string. Cache-stable for every standalone TUI session."""
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
assert out == PLATFORM_HINTS["tui"]
def test_embedded_pane_clarifier_appended_when_env_set(self, monkeypatch):
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
assert out.startswith(PLATFORM_HINTS["tui"])
assert "embedded terminal pane" in out
assert "Shift-drag" in out or "Option-drag" in out or "⌥" in out
def test_embedded_pane_clarifier_idempotent(self, monkeypatch):
"""Calling the clarifier twice must NOT double-append the sentence.
Cache-stability: the resolver is called once per session build, so
re-applying on an already-augmented hint is a no-op."""
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
once = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
twice = _tui_embedded_pane_clarifier(once)
assert once == twice
def test_embedded_pane_clarifier_does_not_touch_empty_hint(self, monkeypatch):
"""Defensive: if the tui hint is somehow empty (e.g. overridden to
empty by config), do not synthesize a clarifier-only hint — that
would put a desktop-pane reference in the prompt without the tui
surface framing it sits under."""
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
out = _tui_embedded_pane_clarifier("")
assert out == ""
@pytest.mark.parametrize("val", ["0", "false", "no", "", "0", "False"])
def test_falsy_env_does_not_trigger_clarifier(self, monkeypatch, val):
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", val)
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
assert out == PLATFORM_HINTS["tui"], (
f"HERMES_DESKTOP_TERMINAL={val!r} should not trigger clarifier"
)
class TestContradictionGone:
"""The original contradiction: a single assembled system prompt
contained both ``You are running in the Hermes terminal UI (TUI).`` and
``Runtime surface: you're running inside the Hermes desktop GUI app.``.
After the fix, no single session's prompt can carry both."""
def test_desktop_chat_session_has_no_tui_framing(self, monkeypatch):
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
assert "tui" in PLATFORM_HINTS
assert "desktop" in PLATFORM_HINTS
desktop_hint = PLATFORM_HINTS["desktop"]
tui_hint = PLATFORM_HINTS["tui"]
assert "terminal UI" not in desktop_hint
assert "terminal UI" in tui_hint
def test_tui_hint_does_not_carry_desktop_marker(self):
tui_hint = PLATFORM_HINTS["tui"]
assert "desktop GUI app" not in tui_hint
assert "Runtime surface:" not in tui_hint

Some files were not shown because too many files have changed in this diff Show More