Compare commits

...
Author SHA1 Message Date
Brooklyn Nicholson 5c1a4eefd3 fix(tui): guard clarify status update + make _clear_pending final
Addresses Copilot review round 5 on #35987.

useMainApp.ts (comment #3330991262): the clarify success path set status
to 'running…' BEFORE the request-id guard, so if the agent emitted a fresh
prompt before the RPC resolved, the new prompt's prompt-specific status was
clobbered. Move the status update inside the request-id check (only set it
on a real answer). sudo/secret already set status inside their guards.

server.py (comment #3330991267): _clear_pending set the empty answer and
the event but left the entry in _pending. A user response landing before
the blocked thread ran its finally could acquire _pending_lock, find the
still-pending request, overwrite the empty answer, and return ok —
reviving a prompt that interrupt/shutdown meant to cancel. Pop _pending
(and its payload) while holding the lock so a later _respond gets 4009.
_block's own finally pop is then a harmless no-op and it still returns the
staged empty answer.

New test test_clear_pending_removes_entry_so_late_respond_cannot_revive
asserts the entry is removed and a late clarify.respond gets 4009.
Mutation-verified: reverting the pop makes the test fail.
2026-05-31 16:59:51 -05:00
Brooklyn Nicholson 8b6207db70 fix(tui): guard success-path overlay clears by request id + tighten race test
Addresses Copilot review round 4 on #35987.

useMainApp.ts (comments #3330951928, #3330951934 — real bug):
- The clarify/sudo/secret SUCCESS callbacks cleared the mounted overlay
  unconditionally. Because _respond() sets the server event (unblocking the
  agent) BEFORE the JSON-RPC response is delivered to the client, the agent
  can emit a fresh prompt.request before the resolve callback runs — so an
  unguarded clear would wipe the new prompt. Guard all three success paths
  by request id, mirroring the failure-path guard. (Copilot flagged sudo +
  secret; swept the pattern and fixed clarify too.)

tests (comment #3330951940 — coverage gap):
- The race stress test recorded prompt.expire events but never asserted
  them against the rounds where clarify.respond succeeded, so a regression
  that fires expiry AND still returns the accepted answer would pass.
  Capture each round's rid and assert respond-ok ⟹ answer delivered AND no
  prompt.expire fired for that request. Mutation-verified: forcing
  should_expire=True now fails the test ("respond succeeded but a false
  prompt.expire fired"), where the prior version passed.
2026-05-31 16:51:59 -05:00
Brooklyn Nicholson 11479a8f31 test(tui): assert prompt.expire surfaces the timeout system line
Addresses Copilot review on #35987: the prompt.expire tests only asserted
the negative (a stale/no-op expiry does NOT emit a system line), so a
regression that dropped the `sys("prompt timed out — <kind> request
cancelled")` call would still pass. Add a positive test covering all
three kinds (clarify/sudo/secret) that asserts a matching expiry which
actually clears a mounted overlay surfaces the timeout line. Verified the
test fails when the sys(...) call is removed.
2026-05-31 16:09:13 -05:00
Brooklyn Nicholson 0351215bf5 fix(tui): make prompt expiry atomic + reset status on dead-overlay dismissal
Addresses the second Copilot review round on #35987.

server.py (race correctness):
- The prior `rid not in _answers` guard was not atomic: _respond() could
  read _pending, get preempted before writing _answers, then _block's
  timeout pops _pending and emits a false prompt.expire even though the
  response is about to be accepted. Introduce a module-level _pending_lock
  and serialize the three critical sections — _respond (membership check +
  answer write + event set), _block's finally (pop + answer check +
  expire decision), and _clear_pending. _emit stays outside the lock
  (transport I/O). _respond no longer writes an answer for an rid that
  _block already popped. New stress test races a responder against the
  deadline across 40 rounds and asserts respond-ok always delivers the
  answer with no false expiry.

useMainApp.ts (client status reset):
- The null-RPC fallbacks for clarify/sudo/secret dismiss a dead overlay
  but left the status bar on the prompt-specific value ('waiting for
  input…' / 'sudo password needed' / 'secret input needed'). Nothing else
  resets it, so the bar kept claiming it was waiting after the prompt was
  gone. Reset to the real busy/ready status in all three fallbacks.

uiStore.ts (single-source the rule):
- Extract statusFromBusy() (was a private copy in createGatewayEventHandler)
  so the gateway-event handler and the useMainApp fallbacks can't drift.
  Add a focused unit test asserting it never resolves to a transient
  prompt status.
2026-05-31 15:07:26 -05:00
Brooklyn Nicholson 31341d6b1d fix(tui): address Copilot review on prompt.expire (race, sudo/secret, status)
Four findings from the PR #35987 Copilot review:

1. Timeout race (_block): _respond() can set _answers[rid] + ev.set()
   after ev.wait() returned False but before the finally block, so the
   answer is accepted yet prompt.expire still fired and cleared the
   overlay out from under it. Guard now re-checks `rid not in _answers`
   (not just the stale `answered` flag) before emitting expiry.

2. Sudo/secret dead-prompt fallback: only clarify cleared its overlay
   on a null respond RPC. sudo/secret share the same _block timeout
   path; if prompt.expire is missed and the user submits after timeout
   their overlay stuck identically. respondWith() gained an optional
   onFail; answerSudo/answerSecret now clear their overlay (matched by
   request_id) when the RPC returns null.

3. Stale status bar: after an expiry cleared a mounted overlay the
   status stayed at the prompt-specific value ("waiting for input…" /
   "sudo password needed" / "secret input needed") while the agent
   streamed. The expire handler now snaps status back via
   statusFromBusy() — but only when it actually cleared the mounted
   overlay (a stale/duplicate expire is now a no-op, including no
   spurious "timed out" system line).

4. Missing secret test: added a secret prompt.expire case, plus tests
   for the status reset and the no-op-stale-expire behavior.

Also adds a Python race-guard test that injects an answer during the
timeout window and asserts no expiry fires (fails without the guard).
2026-05-31 12:36:51 -05:00
Brooklyn Nicholson 17cf1a500f fix(tui): clear stale prompt overlay after server-side timeout
When the assistant emits a clarify/sudo/secret prompt and the user
never answers, the Python-side `_block` in `tui_gateway/server.py`
times out after 5 minutes (120s for sudo), returns an empty string,
and the agent resumes — but the TUI overlay (the `(1-N) quick pick`
choice box) stays mounted because the client was never told the
request expired. The dead overlay then captures every keystroke
while the assistant continues streaming below, so the user
literally cannot escape out or pick anything until the next message
cycle.

Fix:

- `_block` now emits a generic `prompt.expire` event (kind:
  clarify/sudo/secret, plus the original request_id) when the wait
  times out without an answer.
- The TUI handles `prompt.expire` by clearing the matching overlay
  if the request_id still matches the currently-mounted prompt.
  Match-by-id avoids clobbering a fresh prompt that opened in the
  meantime.
- Belt-and-suspenders: when `clarify.respond` fails with the
  "no pending clarify request" error (server-side already expired),
  the client now also clears the overlay locally so a user keystroke
  after timeout doesn't leave the box stuck.

Adds three Python tests (`prompt.expire` fires on timeout, doesn't
fire on legitimate answer, kind matches event prefix) and three
TUI tests (`prompt.expire` clears the matching clarify/sudo overlay
and ignores stale request_ids).
2026-05-31 12:21:15 -05:00
Teknium 1044d9f25d fix(gateway): /stop can interrupt a sibling participant's run in a per-user thread (#35959)
In a per-user thread (thread_sessions_per_user=True), each participant
gets an isolated session key (...:{thread_id}:{user_id}). A run another
user started lives under a different key, so the caller's own /stop found
nothing and replied 'no active task to stop'.

When /stop finds no run under the caller's own key, fall back to
interrupting any running agent(s) sharing the caller's thread prefix
({chat_id}:{thread_id}), gated on _is_user_authorized. Thread-only — the
fallback returns [] for non-thread channels, and a prefix-collision guard
prevents thr1 from matching thr11.
2026-05-31 09:29:03 -07:00
Teknium de4f40ed02 feat(setup): thin out setup — Quick Setup via Nous Portal + Full Setup defaults (#35723)
* feat(setup): Quick Setup routes through Nous Portal (OAuth + model + messaging)

First-time quick setup now goes straight to the Nous Portal provider
instead of showing the full provider picker. Runs the device-code OAuth
login, selects a Nous model, configures the terminal backend, and offers
messaging setup — applying recommended defaults for everything else.

- Rename menu entry to 'Quick Setup (Nous Portal)'.
- _run_first_time_quick_setup now calls _model_flow_nous (handles both the
  logged-out OAuth+model-select path and the logged-in curated picker),
  then re-syncs config from disk to avoid the #4172 stale-overwrite.
- Terminal / defaults / messaging steps unchanged.

* feat(setup): thin out Full Setup with happy defaults

Full Setup no longer asks for every config knob — anything with an
obvious default is applied silently and stays tunable via the per-section
commands (hermes setup agent|terminal|tts, hermes auth add).

- Model section: drop the same-provider rotation pool, vision-backend
  picker, and TTS provider sub-flows. Vision auto-detects from the main
  provider; TTS defaults to Edge; rotation lives in hermes auth add.
- Terminal section: keep the backend picker (Local default) and any
  required credentials (Modal token, SSH host/user/key, Daytona key),
  but stop prompting for container image, CPU/mem/disk resources, gateway
  cwd, and sudo password — all use defaults.
- Agent Settings: removed from the wizard. First installs get recommended
  defaults silently; existing installs keep their tuned values.
- New defaults: max_turns 90 -> 150, session_reset both -> none.
- Tests: reconfigure tests assert agent settings are no longer prompted
  on existing installs; drop 3 tests covering the deleted in-setup
  rotation flow.
2026-05-31 09:13:06 -07:00
brooklyn! a726e8a811 fix(tui): auto-recover session on unexpected gateway death (+ persist lifecycle breadcrumbs) (#35893)
* fix(tui): persist gateway lifecycle breadcrumbs to crash log

A backend SIGTERM (`=== SIGTERM received ===` in tui_gateway_crash.log) is
always a parent action — `gw.kill()` (graceful-exit on a signal to Node, or an
explicit /quit) or `start()` replacing a live child. #31051 added parent-side
lifecycle breadcrumbs but left them in an in-memory CircularBuffer that dies
with the process, so SIGTERM crash reports arrive with no parent context and no
way to tell a signal-driven kill from a memory-critical `process.exit(137)`
(which closes the child's stdin → clean EOF, not SIGTERM).

Persist the death-explaining breadcrumbs (spawn / transport-exit / child-exit /
replace-live-child / kill-reason / startup-timeout) plus the graceful-exit
signal name and the memory-critical exit into the same crash log the Python
side writes, so they interleave by timestamp next to the child's panic entry —
making these recurring reports diagnosable.

Gated off under VITEST so unit tests stay hermetic.

* feat(tui): auto-recover the session when the gateway dies unexpectedly

When a still-owned gateway child dies while the TUI is alive (a crash, OOM
process.exit, or a SIGTERM/SIGHUP forwarded to it), the app currently nulls the
session and drops to an inert "gateway exited" state — the user loses a long
session and has to restart + re-run everything. That single behavior is most of
the "TUI doesn't survive heavy work" complaint, independent of what does the
killing.

The 'exit' event only reaches this handler on an *unexpected* death: a user
/quit calls process.exit before it fires, and a replaced child is identity-
skipped in GatewayClient. So on exit we now respawn the gateway and resume the
session that was live (history is persisted in SQLite) via a one-shot
recoverSidRef the next gateway.ready consults before forging a new session. The
in-flight reply is lost (it died with the process) but the session survives.

Bounded to GATEWAY_RECOVERY_LIMIT (3) attempts per GATEWAY_RECOVERY_WINDOW_MS
(60s) so a gateway that crash-loops on startup can't spawn-storm; past the
budget we fall back to the inert state.

* fix(tui): sanitize newlines + soften SIGTERM-cause claim in parentLog

Address PR review:
- recordParentLifecycle collapses embedded \r\n so a multi-line value (e.g. an
  error message) stays a single breadcrumb and can't masquerade as a separate
  entry or as the child's panic output sharing the crash log.
- Reword the header: a backend SIGTERM is *usually* a parent action but can come
  straight from an external supervisor (s6, cgroup OOM, stray kill); the
  presence/absence of a [tui-parent] line before the child's panic is precisely
  what disambiguates the two.

* fix(tui): clear sid during recovery + extract/test the recovery budget

Address PR review:
- Null `sid` immediately in the gateway exit handler. While the gateway is down
  (busy=false) the old sid would otherwise let sid-guarded effects (the 1.5s
  session.active_list poll, queue drain) fire RPCs at a dead/respawning gateway.
  recoverSidRef carries the session forward; resumeById restores sid on ready.
- Extract the respawn budget into a pure evalRecovery() (gatewayRecovery.ts) and
  unit-test the bound: allows GATEWAY_RECOVERY_LIMIT within the window, blocks
  past it, and prunes attempts older than the window so recovery re-arms.

* fix(tui): cap parent-log breadcrumb length (PR review)

Truncate a single persisted breadcrumb to 4096 chars (matching GatewayClient's
in-memory log-line cap) so a pathological value — e.g. a giant error string —
can't bloat the shared crash log or add noticeable blocking on the synchronous
append during a failure path. Covered by a test.

* fix(tui): keep "recovering session…" status visible during resume (PR review)

resumeById() synchronously sets status to 'resuming…' on entry, so the
recovery branch now applies its 'recovering session…' label *after* calling
resumeById — the distinct label sticks for the duration of the resume RPC
(which later flips to 'ready') instead of being immediately clobbered. Test
updated to assert the ordering.

* fix(tui): keep recovery budget alive across a startup crash-loop (PR review)

deadSid was read from getUiState().sid, which the first exit nulls — so if the
respawned gateway crash-looped before gateway.ready (resumeById never restored
sid), later exits saw null and abandoned the session after a single attempt,
defeating the bounded retry budget.

Lift the whole decision into a pure planGatewayRecovery() that falls back to the
pending recoverSidRef target when the live sid is already cleared, and unit-test
the crash-loop sequence (keeps retrying the same session up to the limit, then
falls back to inert). Supersedes evalRecovery.

* chore(tui): drop non-null assertion + clarify breadcrumb cap comment (PR review)

- Recovery branch guards on `recoverSidRef && recoverSid` so the ref write needs
  no `!` assertion (avoids a future unsafe refactor).
- Reword the parentLog cap comment: it slices the value to 4096 chars and
  appends a short truncation marker (so the written line is slightly longer),
  rather than implying a strict 4096-byte limit.

* chore(tui): soften "absence ⇒ external signal" + "any in-flight reply" (PR review)

- parentLog header: a missing [tui-parent] line only *suggests* an external
  signal (the logger is best-effort: VITEST-disabled, failed append swallowed),
  not a definitive conclusion.
- Recovery notice says "any in-flight reply was lost" since the gateway can also
  exit while idle.
2026-05-31 10:36:57 -05:00
teknium1 04bb74c58e chore: map fesalfayed author email for release notes 2026-05-31 06:14:34 -07:00
fesalfayed 64628ea89b fix(anthropic): demote dead thinking signature when orphan-strip mutates the latest turn
Extended-thinking Claude models (4.6+, e.g. Opus 4.8) emit a signed `thinking`
block on assistant turns that also carry parallel `tool_use` blocks. Anthropic
signs that block against the full, original turn content.

When a parallel tool batch is interrupted before every `tool_result` returns,
`_strip_orphaned_tool_blocks` removes the unanswered `tool_use` on replay — which
mutates the turn. The latest-assistant branch of `_manage_thinking_signatures`
then replays the now-stale signed thinking block verbatim, and Anthropic rejects
the request with a non-retryable HTTP 400:

    messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest
    assistant message cannot be modified. These blocks must remain as they were
    in the original response.

Because the poisoned turn is rebuilt from the persisted store every turn, the
gateway crash-loops with no self-recovery (a soft session reset does not clear
it). The drifting content index in the error is the changing count of stripped
`tool_use` blocks across rebuilds.

Fix: when orphan-stripping removes a `tool_use` from a turn that also holds a
thinking/redacted_thinking block, flag the turn. `_manage_thinking_signatures`
then demotes every thinking block on that latest turn to a plain text block
(preserving the reasoning text) instead of replaying a signature that can no
longer validate. An intact turn is unaffected — its signed thinking is still
replayed verbatim. The internal flag is stripped before the payload is sent.

Adds two regression tests:
- demotion when an orphaned parallel tool_use is stripped
- control: signed thinking preserved verbatim when nothing is stripped
2026-05-31 06:14:34 -07:00
Teknium 2b5268f716 revert: drop cumulative-resend tool-arg heuristic from shared streaming path (#35718) (#35860)
PR #35718 added a per-slot "cumulative-resend" latch to the universal
streaming tool-call accumulator to fix DeepSeek / Baidu Qianfan (#35592).
The latch fires when a delta is a strict superset of the accumulated
buffer (len(_new) > len(_prev) and _new.startswith(_prev)) and then
REPLACES the buffer instead of appending.

That superset test is not an unambiguous cumulative signature. A normal
incremental stream can emit a single fragment that restates an already-
accumulated prefix — trivially common in large code-patch arguments with
repeated lines / indentation — which trips the latch and clobbers the
accumulated buffer, corrupting the tool call. Observed in the wild on
Anthropic Opus (the primary model) building a large patch: corrupted /
short arguments → finish_reason='length' dead-end → session killed.

A guessing heuristic that can silently clobber a tool-call buffer has no
place on the path every provider and model shares. Reverting restores the
known-good plain `+=` accumulator. The #35592 narrow provider bug should
be re-addressed provider-gated so it is structurally impossible to touch
Anthropic / OpenAI incremental streams, rather than via a heuristic on the
shared path.

Reverts ca03486b6.
2026-05-31 06:14:32 -07:00
Teknium f2d4cf4f76 fix(cli): clamp post-compression token sentinel in status bar (#35858)
The status bar read context_compressor.last_prompt_tokens directly with
an 'or 0' guard that only catches 0/None. Right after a compression the
compressor parks last_prompt_tokens at the -1 sentinel
(awaiting_real_usage_after_compression) until the next API call reports
real usage. -1 is truthy, so it sailed through and rendered as '-1/200K'
and '-1%' for that one transitional turn.

Clamp negative token/context-length values to 0 in the status-bar
snapshot so the gap reads as empty context until real usage arrives.
2026-05-31 06:03:01 -07:00
Teknium 1fc7bdc5e6 feat(tools): always show Nous Tool Gateway backends, login on select (#35792)
* feat(tools): always show Nous Tool Gateway backends, login on select

The Nous-managed Tool Gateway rows in `hermes tools` (Firecrawl, OpenAI
TTS, Browser Use, FAL image/video) were hidden unless the user was already
logged into Nous Portal with paid access. Now they are always listed.
Selecting one runs an inline Nous Portal device-code OAuth + entitlement
check — auth only, no inference-provider switch and no bulk 'enable all
tools' prompt (that stays in `hermes model`). The row only activates the
gateway once paid access is confirmed.

- _visible_providers: stop hiding managed_nous_feature rows (incl. those
  also flagged requires_nous_auth); pure pre-auth UX rows still gate on login
- nous_subscription.ensure_nous_portal_access(): auth + entitlement gate
  that preserves the user's active inference provider
- _configure_provider / _reconfigure_provider: run the inline gate for
  managed backends; write config only when entitled
- picker marker: 'via Nous Portal (login on select)' for logged-out users
- _hidden_nous_gateway_message: now a no-op (rows are never hidden)

* docs: hermes tools is a first-class Tool Gateway entry point

The Tool Gateway docs framed `hermes setup --portal` / `hermes model` as
the activation path and only mentioned `hermes tools` for mixing in your
own keys. With the inline-login change, picking a Nous-managed backend in
`hermes tools` is a complete path on its own — it logs you into Nous
Portal on select if needed, without switching your inference provider or
prompting to enable every other tool.

- tool-gateway.md: Get started now lists three peer entry points; new
  paragraph explaining login-on-select and the no-prompt fast path when
  OAuth is already active
- nous-portal.md + run-hermes-with-nous-portal.md: note that managed rows
  appear logged-out and trigger inline login on select
2026-05-31 03:39:17 -07:00
kshitijk4poor 8f4c8e7c82 refactor(cli): extract shared curses menu event-loop driver
The three curses menus (curses_checklist / curses_radiolist /
curses_single_select) each hand-rolled an identical event loop: cursor
hide + color-pair init, the per-frame clear/getmaxyx/refresh cycle,
scroll-offset math, row iteration, the read_menu_key dispatch with
NAV_UP/NAV_DOWN cursor wrap, flush_stdin, and the
KeyboardInterrupt/curses-unavailable fallback. Terminal-behavior changes
(e.g. Ghostty raw-escape handling, scroll tweaks, a new key) had to be
made in three places.

Extract that boilerplate into one _run_curses_menu driver. Each public
menu now supplies small callbacks for the parts that genuinely differ:
draw_header (returns the item-list start row), draw_row (checkbox vs
radio vs bare prefix), an on_action reducer (toggle-set vs return-cursor
vs return-None + the single_select cancel-row guard), an optional
draw_footer (the checklist status bar), reserve_bottom, and the numbered
fallback. Behavior is passed as functions; the loop is the only stateful
piece — so future terminal/Ghostty work is a one-place edit.

Duplicated event-loop primitives drop 3 -> 1 (stdscr.clear, read_menu_key
dispatch, scroll math). Verified byte-identical: a render harness records
every addnstr(y, x, clamped-text, attr) call across frames plus the
return value for 6 cases (checklist, checklist+status, radiolist,
radiolist+description, single_select, single_select ESC-cancel); output
diffs clean against origin/main. Non-TTY returns the cancel value
directly (not the input()-based numbered fallback), matching the old
per-menu guard. 150 menu/setup/browse/plugins tests pass.
2026-05-31 03:19:37 -07:00
kshitijk4poor 087be00733 fix(cli): migrate setup model/provider pickers off simple_term_menu to curses
The setup provider->model sub-menu (and three sibling pickers) used
simple_term_menu.TerminalMenu, whose ESC and arrow-key handling was
unreliable across terminals — notably ESC failed to back out of the
model selection list on terminals that emit raw escape sequences (e.g.
Ghostty). The codebase already notes simple_term_menu 'conflicts with
/dev/tty' and causes 'ghost-duplication rendering', and a prior attempt
to migrate these (closed PR) confirmed the same root cause.

Route all four single-select pickers through the shared, already-hardened
curses_radiolist (which decodes raw CSI/SS3 escape sequences and handles
ESC consistently, fixed in #35776):

- auth.py _prompt_model_selection — model picker; the pricing column
  header and the unavailable-models block are passed as the radiolist
  description so they survive the curses screen clear. ESC now cancels.
- main.py _prompt_reasoning_effort_selection — reasoning-effort picker.
- main.py _model_flow_named_custom — named custom-provider model picker.
- main.py _remove_custom_provider — provider-removal picker.

simple_term_menu is no longer imported anywhere (only stale comments
referenced it; one in setup.py is corrected). The numbered-input
fallbacks are unchanged and still trigger on curses errors / non-TTY.

Tests: updated test_terminal_menu_fallbacks / test_reasoning_effort_menu
/ test_custom_provider_model_switch / test_model_provider_persistence to
drive the fallback via curses_radiolist errors instead of breaking
simple_term_menu. New test_setup_menu_curses_migration.py asserts each
picker routes through curses_radiolist, ESC cancels, and the pricing
header is preserved. Net -147/+183 (mostly the new test file; production
code shrinks by removing TerminalMenu boilerplate).
2026-05-31 03:19:37 -07:00
kshitij 4ccd141b15 Merge pull request #35776 from kshitijk4poor/fix/curses-arrow-key-decode
fix(cli): decode raw arrow-key escape sequences in curses menus
2026-05-31 01:41:31 -07:00
kshitijk4poor 3463c97a36 fix(cli): decode raw arrow-key escape sequences in curses menus
The setup wizard's provider/model pickers (curses_radiolist via
prompt_choice) bailed to the numbered "Select [1-N]" fallback the moment
a user pressed up or down. Root cause: even with keypad(True) — which
curses.wrapper sets — many terminals/terminfo entries deliver cursor keys
to getch() as raw CSI/SS3 byte sequences (e.g. 27, 91, 66 for arrow-down)
rather than the translated curses.KEY_DOWN. The menus matched only
curses.KEY_UP/KEY_DOWN and treated the leading 27 (ESC) as cancel, so
navigation dropped into the text fallback and the trailing bytes leaked
into the next input().

Add a shared read_menu_key() helper that decodes CSI/SS3 escape sequences
into normalized NAV_* actions (only a lone ESC, with no continuation byte
within a short timeout, still cancels) and consumes the tail of unhandled
sequences so stray bytes can't corrupt later input(). Route all three
curses menus (checklist, radiolist, single_select) through it.

Add regression tests covering raw CSI/SS3 arrows, translated KEY_*
constants, vim keys, lone-ESC cancel, and full consumption of unhandled
sequences (Delete/Home/End).
2026-05-31 13:59:56 +05:30
Teknium 0cd7d54b00 feat(kanban): goal_mode cards run workers in a /goal loop (#35710)
* feat(kanban): goal_mode cards run workers in a /goal loop

A goal_mode card wraps its dispatched worker in the Ralph-style goal
loop behind /goal: after each turn an auxiliary judge checks the
worker's response against the card title+body, and if not done the
worker keeps going in the SAME session until the judge agrees, the
worker terminates the task itself, or the turn budget runs out (which
blocks the card for human review — never a silent exit).

- kanban_db: goal_mode + goal_max_turns columns (additive migration),
  Task fields, create_task params, INSERT wiring, created-event payload.
- kanban_tools: goal_mode/goal_max_turns on the kanban_create tool so
  orchestrators can opt cards in when fanning out.
- kanban CLI: --goal / --goal-max-turns on 'kanban create'.
- dashboard API: goal_mode/goal_max_turns on the create endpoint
  (auto-surfaced back via asdict).
- _default_spawn: sets HERMES_KANBAN_GOAL_MODE / _GOAL_MAX_TURNS only
  when the card opts in.
- goals.run_kanban_goal_loop: standalone, callback-injected loop engine
  (no SessionDB persistence; ephemeral worker). cli.py quiet path calls
  it after the worker's first turn when the env vars are set.
- Docs: orchestrator skill + kanban feature page.

Tests: DB roundtrip + legacy migration, spawn env gating, and the loop's
continuation/completion/budget-block/finalize-nudge branches. E2E run
against a real kanban DB confirms a budget-exhausted goal worker lands
in a sticky blocked state.

* feat(kanban/dashboard): goal-mode toggle in the create form

Wires the goal_mode card setting into the dashboard UI (the plugin's
hand-written IIFE bundle, no build step):

- InlineCreate: 'goal mode' checkbox after the skills field; checking it
  reveals an optional 'max turns' number input. Both reset on submit and
  only post goal_mode/goal_max_turns when enabled.
- TaskDrawer: a 'Goal mode: on (max N turns)' MetaRow so a card's
  goal-mode setting is visible after creation (auto-fed by asdict via the
  existing _task_dict).

Live-tested through the running dashboard with a browser: created a
goal-mode card with max-turns=8, confirmed it persisted to the kanban DB
(goal_mode=1, goal_max_turns=8) and rendered back in the drawer as
'on (max 8 turns)'. No JS console errors.
2026-05-31 01:16:33 -07:00
kshitijk4poor 32899279a7 fix(gateway): detach pending_watchers batch + normalize LRU caches + align test fixtures + AUTHOR_MAP
Self-review follow-up on top of the salvaged perf fixes:

- gateway/run.py (both watcher-drain sites): the salvaged O(n^2) fix
  (#32708) replaced `while pending_watchers: pop(0)` with iterate-then-
  `watchers.clear()`, but `watchers` aliased the registry's live list.
  A watcher appended by a concurrent session during the `await
  asyncio.sleep(0)` yield would be cleared without ever being scheduled.
  Detach the batch atomically (`pending_watchers = []`) before iterating.

- gateway/platforms/bluebubbles.py: normalize the salvaged _guid_cache
  LRU (#30523) to match feishu/codebase precedent — module-level
  `_GUID_CACHE_SIZE` constant, `while len > cap`, and drop the redundant
  post-insert `move_to_end` (a fresh insert is already most-recent).

- gateway/platforms/feishu.py: drop the same redundant post-insert
  `move_to_end` from the salvaged _message_text_cache LRU (#23706).

- scripts/release.py: add AUTHOR_MAP entries for the salvaged commits'
  authors (amathxbt #22155, ErnestHysa #32636/#32708) so the contributor
  audit passes when these commits land on main.

- tests/tools/test_tool_output_limits.py: autouse fixture resets the new
  module-level limits cache between tests.

- tests/gateway/test_feishu.py: hand-built adapter fixture seeded
  _message_text_cache as a plain dict; it's now an OrderedDict, so the
  fixture type had to match.
2026-05-31 00:50:19 -07:00
ErnestHysa 0036c72923 fix(gateway): upgrade plugin/bundle error logging and fix O(n^2) watcher recovery
N43 — Silent plugin/bundle errors:
- Plugin command dispatch: logger.debug() -> logger.warning()
- Bundle dispatch: logger.debug() -> logger.warning()
Plugin/auth failures are no longer invisible to operators.

N42 — O(n^2) pending_watchers recovery:
- Both recovery loops (startup + per-message) used while+pop(0) which is O(n) per pop
- Replaced with enumerate() over the list + periodic asyncio.sleep(0) yield points
- Clears the list after iteration instead of per-pop
- Batch size of 100 balances throughput vs event-loop responsiveness
2026-05-31 00:50:19 -07:00
ErnestHysa eb9bfd3924 fix(T5): replace time.sleep(0.25) with asyncio.sleep in MCP auth reconnect poll
PAIN BEFORE:
Inside _handle_auth_error_and_retry() (a sync function that runs on the MCP
event loop thread), there was a blocking polling loop:

    while time.monotonic() < deadline:
        if srv.session is not None and srv._ready.is_set():
            break
        time.sleep(0.25)   # BLOCKS THE ENTIRE EVENT LOOP

Since _handle_auth_error_and_retry is invoked from tool handlers that run ON
the MCP event loop, time.sleep(0.25) blocked ALL concurrent MCP operations
(including other tools, keepalive heartbeats, OAuth refreshes) for 250ms per
iteration. With a 15-second deadline, worst case = 60 * 250ms = 15 seconds
of fully blocked concurrency.

WHAT WAS FIXED:
Extracted the blocking poll into an async helper _await_ready() that uses
asyncio.sleep(0.25) (non-blocking), and runs it via _run_on_mcp_loop().
_run_on_mcp_loop() properly awaits the coroutine on the event loop without
blocking the caller's thread. Added exception handling around the poll so
stuck reconnects still fall through to the error path.

The sync _handle_auth_error_and_retry now:
1. Fires reconnect signal (threadsafe)
2. Calls _run_on_mcp_loop(_await_ready(), timeout=15) — non-blocking
3. Returns; the event loop handles the polling

File: tools/mcp_tool.py
Lines: _handle_auth_error_and_retry() (~1886-1920)

Found by: exhaustive multi-pass audit (10 strategies, 1901 files, 913K lines)
2026-05-31 00:50:19 -07:00
AMATH 91a98d1519 fix: tool_output_limits re-reads config on every call (no caching) 2026-05-31 00:50:19 -07:00
Yuan Li 3c21fed099 fix(bluebubbles): cap _guid_cache with LRU eviction to prevent unbounded growth
The _guid_cache dict grows without bound as new contacts/groups are
resolved.  In a long-running gateway instance with many unique targets
this becomes a slow memory leak.

Replace the plain dict with an OrderedDict capped at 500 entries.
When the cap is exceeded the oldest (least-recently-used) entries are
evicted.
2026-05-31 00:50:19 -07:00
EloquentBrush e8cacb57d5 fix(feishu): cap _message_text_cache with LRU eviction to prevent unbounded growth
_message_text_cache was a plain dict with no size limit. Every unique
message_id whose text was fetched (for reply-context lookups) stayed in
memory permanently, causing unbounded growth in long-running deployments
with active group chats.

Replace with an OrderedDict and evict the least-recently-used entry
whenever the cache exceeds _FEISHU_MESSAGE_TEXT_CACHE_SIZE (512). Cache
hits call move_to_end() to refresh LRU order. Mirrors the identical
pattern already used by _pending_processing_reactions in the same class.
2026-05-31 00:50:19 -07:00
Teknium e1293bde4e feat(models): refresh model catalog hourly instead of daily (#35756)
Lower the model_catalog disk-cache TTL from 24h to 1h so freshly
published model-catalog.json deploys reach the picker within an hour
instead of up to a day. The picker now refetches on the next
`hermes model` / `/model` once the cache is older than 1h; younger
than 1h still serves the cache (no network hit), and network failures
still fall back to the stale copy.

- DEFAULT_TTL_HOURS 24 -> 1 (model_catalog.py)
- DEFAULT_CONFIG model_catalog.ttl_hours 24 -> 1, _config_version 24 -> 25
- migration v24->25 rewrites a stale ttl_hours:24 to 1, preserving any
  custom value the user set

E2E: verified >1h refetches / <1h skips, and migration rewrites 24->1
while preserving a custom 6.
2026-05-31 00:29:40 -07:00
Teknium ca03486b6a fix(streaming): stop duplicating tool-call args from cumulative-resend providers (#35718)
DeepSeek / Baidu Qianfan stream tool-call arguments in cumulative mode:
each chunk resends the full arguments-so-far instead of the new fragment.
The stream accumulator blindly concatenated arg deltas with +=, turning
that into '{...}{...}{...}', which failed json.loads and got nuked to '{}'
— a silently corrupted tool call (#35592). Worse on multi-param tools
(search_files, session_search, memory replace) because longer args take
more chunks, giving more resend opportunities.

- Per-slot cumulative latch in the stream accumulator: a delta that is a
  strict superset of the accumulated buffer marks the slot cumulative and
  replaces (not appends); exact duplicates are dropped only after latching.
  Incremental fragments are untouched (default += path).
- Backstop _collapse_repeated_json_arguments() in the repair pipeline
  collapses pure identical-resend buffers (K exact repeats of a valid-JSON
  unit) for providers that resend the complete object from chunk 1. Only
  reached after json.loads already failed, so compliant single objects are
  never touched.

Not a gateway or DeepSeek-model bug — any OpenAI-wire provider in
cumulative streaming mode is affected.
2026-05-31 00:19:39 -07:00
Teknium 0ffbcbbe7d fix(vision): cap embedded image size before it wedges a session (#35732)
Resize vision tool-result images down to a 4 MB embed cap at load time,
not just at the 20 MB hard ceiling. A 5-20 MB image previously sailed
through the native fast path and got baked into conversation history,
where Anthropic's 5 MB per-image base64 limit rejected every subsequent
turn with a 400 — and because history is immutable, retries could never
clear it, permanently wedging the session.

Also harden the reactive shrink-recovery: it now returns False (don't
retry) when any oversized image part can't be brought under target, so
the single retry isn't burned re-sending a payload that will fail
identically. Previously it returned True after shrinking *any* part,
even when the actual oversized culprit survived.
2026-05-31 00:12:09 -07:00
Teknium d4e7b2fc19 fix(voice): allow /voice over SSH when a sound server is reachable (#35719)
SSH sessions hard-failed voice mode on the presence of SSH_* env vars
alone, even when a PulseAudio/PipeWire server is running on the host and
audio works (ffplay/aplay/pw-play -> pulseaudio). Probe the default
sound-server sockets (PULSE_SERVER unix path, PULSE_RUNTIME_PATH/native,
$XDG_RUNTIME_DIR/{pulse/native,pipewire-0}) and actually connect() so a
stale socket doesn't count; downgrade the SSH branch to a notice when
audio is reachable. Mirrors the existing Docker/WSL forwarding handling.

Fixes #35622
2026-05-31 00:11:52 -07:00
Teknium d276018378 docs(toolsets): clarify all/* wildcard does not enable kanban (#35729)
The all/* wildcard expands to every registered toolset, but a handful of
tools have an additional check_fn gate on top of toolset membership and
are intentionally NOT turned on by all/* alone:

- Capability-gated tools (browser, computer_use, code_execution, Feishu,
  Home Assistant, cronjob) require their backend/credential prerequisite.
- The kanban toolset is workflow-gated and deliberately opt-in. Kanban
  tools mutate shared board state, so they stay off by default even under
  all/* — you must list 'kanban' by name (or be a dispatcher-spawned
  worker with HERMES_KANBAN_TASK set).

This was the expectations gap behind #35581 — the docs previously said
all/* expands to 'every registered toolset' without noting the carve-out.

Closes #35581.
2026-05-31 00:10:50 -07:00
teknium1 bd72d333dc fix(gateway,cron): reuse existing _HERMES_GATEWAY marker; tighten cron regex
Follow-up to the salvaged #30728:
- Gateway already exports _HERMES_GATEWAY=1 at startup (gateway/run.py) and
  cli.py already keys off it. Drop the redundant new HERMES_IN_GATEWAY var;
  guard stop/restart on _HERMES_GATEWAY instead. One marker for one fact.
- Drop the greedy \bgateway.*restart alternation from the cron lifecycle
  filter — it false-positived on legit prompts that merely mention an
  unrelated gateway + a restart (API/payment gateway monitoring). The
  specific 'hermes gateway (restart|stop|start)' pattern already covers the
  real command.
- Rework the two negative guard tests to sentinel the first downstream call
  so they don't drive real signal delivery (tripped the live-system guard).
- Add false-positive regression cases to test_safe_commands.
2026-05-30 23:05:56 -07:00
simokiihamaki 5cd6c1717d fix(gateway,cron): prevent agent restart loops via self-targeting gateway commands (#30719)
Three defenses against SIGTERM-respawn loops when agent schedules its
own gateway restart under launchd/systemd KeepAlive:

1. HERMES_IN_GATEWAY env var: gateway sets it at startup; stop/restart
   subcommands refuse to run when set (exit 1 with clear message).

2. Cron create payload filter: regex pre-flight rejects prompts/scripts
   containing hermes gateway restart/stop, launchctl kickstart/unload,
   systemctl restart/stop, and pkill patterns.

3. 30 new tests: pattern matching (14), cron block (5), gateway guard (4),
   safe command negatives (7).
2026-05-30 23:05:56 -07:00
Teknium 9b78f411c8 fix(security): neutralize file paths in mutation-verifier footer (#35584) (#35684)
The per-turn file-mutation verifier footer rendered failed-write paths as
bare absolute paths in the user-facing response. The gateway's
extract_local_files() scans response text for bare paths ending in a
deliverable extension (.yaml/.json/etc.), validates os.path.isfile(), and
auto-attaches matches as native uploads — so a denied write to
~/.hermes/config.yaml surfaced the path in the footer and got the
credential file silently uploaded to the messaging channel.

The gateway denylist (validate_media_delivery_path) already blocks the
config.yaml case after #35634. This is defense-in-depth at the source:
backtick-wrap every path the footer emits — both the bullet path and any
path echoed inside the tool's error preview (the protected-file denial
message embeds the path in single quotes, which do NOT block the
extractor regex). extract_local_files skips paths inside inline-code
spans, so wrapping defeats auto-attachment for ANY protected file while
keeping the path human-readable.

- run_agent.py: _format_file_mutation_failure_footer wraps bullet paths;
  new _neutralize_footer_paths backticks any remaining bare path (covers
  the preview echo). staticmethod -> classmethod (caller unaffected).
- tests: backtick-wrap assertion + end-to-end extract_local_files leak test.
2026-05-30 23:05:23 -07:00
Teknium dc4de14377 fix(telegram): retry on httpx pool timeout instead of dropping the send (#35664)
When PTB's general httpx pool is exhausted, it converts httpx.PoolTimeout
into telegram.error.TimedOut whose message states the request was *not*
sent to Telegram. The send retry loop treated all non-connect TimedOut as
non-retryable, so a pool timeout raised immediately, skipped all 3 retry
attempts, and was returned as retryable=False -- silently dropping the
message (agent responses, cron reports, etc.).

A pool timeout means the request never left the process, making it the
safest case to retry. Add _looks_like_pool_timeout() and treat it like a
connect timeout in both the in-loop retry decision and the outer retryable
determination, so pool timeouts flow through the existing backoff loop and
stay retryable on exhaustion.

Reported-by: q3874758 (#35610)
2026-05-30 22:58:16 -07:00
LeonSGP43 02d1da49de Block Hermes root config in media delivery 2026-05-30 21:02:36 -07:00
Teknium 50db2d9c12 feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists

deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).

* refactor(models): trim redundant variants, group curated lists by maker

Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).

* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists

Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).

* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures

The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
teknium1 fe62424ac4 test(redact): assert Discord mentions pass through unchanged
Rewrite TestDiscordMentions as negative assertions (mentions survive the
redactor) and clean up the orphaned comment + dangling whitespace left by
removing _DISCORD_MENTION_RE. Follow-up to the salvaged #32259 fix for #35611.
2026-05-30 20:48:41 -07:00
BarnacleBoy c2cbe2c97d fix: remove Discord mention redaction from secret scrubber 2026-05-30 20:48:41 -07:00
Teknium 9ed9af2f7d fix(update): name new config options in migration prompt; skip prompt for pure version bumps (#35658)
The 'hermes update' config-migration prompt printed only counts ('1 new
config option available') then asked 'configure them now?' without ever
saying what the options were. Users said no because they couldn't tell what
they were agreeing to. For pure config-format version bumps (no new
env/config keys) it still asked the question, where saying yes just bumped
the version and looked like a no-op.

- List each new env var / config key by name + description before prompting
  (cap at 8, then '… and N more'). The data was already available; we just
  threw it away and printed a count.
- Pure version bump (no new options): apply the format migration
  non-interactively and print what happened, instead of asking a misleading
  yes/no.

Reported by ScottFive and Tt2021.
2026-05-30 20:42:37 -07:00
Teknium b1d34cf6e2 fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657)
Some hosts (notably WSL) report a junk window size such as 131072 columns
by 1 row. Both the Ink fork and our components only guard against
0/null/undefined/NaN (stdout.columns || 80), so a positive-but-absurd
width sails through into createScreen(width*height), allocating tens to
hundreds of MB per frame and tripping the TUI memory monitor's hard exit.

Add clampStdoutDimensions(), installed in entry.tsx before ink.render: it
patches process.stdout.columns/rows with clamping getters (cols 1-2000,
rows 1-1000; out-of-range -> 80x24). One install point fixes the renderer,
its resize handler, and every component read. Live resizes still propagate
through the original descriptor, just clamped.
2026-05-30 20:42:30 -07:00
brooklyn!andCopilot Autofix powered by AI cd067ab91e fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512)
* fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer

When the Node event loop blocks during a heavy render/tool-call burst, stdin
stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel
buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND
coordinate digits chewed off across many partial reads. The existing fragment
recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]`
triples, so the degraded shards leak into the composer as typed text — the user
can no longer type or exit until the stall clears.

Captured leak (Windows Terminal, during tool calls):

  M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M
  48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M
  M18M99;26M32MM38M63;44M47MM1;51M M4M54M

Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path:

- MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only
  from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries
  the structural signature of mouse coordinates (>=3 M/m terminators, a digit,
  and a `;`), swallow it wholesale.
- MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and
  after recovered fragments, so a partially-recovered burst doesn't trail a
  chewed-up tail into the prompt.

All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no
digit/`;`, `see 1;2;3M for details` has disqualifying letters, and
`1234;56;78M9;10;11M` has only two terminators — none are swallowed.

This is defense-in-depth: it stops the leak/lockout regardless of what blocks
the loop. The underlying event-loop stall during streaming is a separate,
still-open issue that needs live-turn instrumentation to root-cause.

* fix(tui): check mouse-burst noise before fragment recovery; drop test cast

Copilot review on #35512:

- MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments
  returned null. A noise blob that contains any intact `<b;c;r M` fragment makes
  fragment recovery return non-null, so the whole-text swallow never fired and
  the code emitted a pile of recovered mouse events instead of dropping the blob
  wholesale (contradicting the comment, and doing extra work mid-stall). Move the
  noise check ahead of fragment recovery so pure-noise tokens are dropped early.
  Add a regression test for a noise blob carrying intact fragments.

- Drop the unnecessary `(e as { isPasted?: boolean })` cast in the test;
  discriminated-union narrowing on `e.kind === 'key'` exposes isPasted directly.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-30 22:27:14 -05:00
helix4u 355af2c20f fix(session): survive missing FTS5 runtimes 2026-05-30 18:59:08 -07:00
Teknium ec67def5bf fix(install): refresh stale uv so installs actually get FTS5 Python (#35541)
The installer's ensure_fts5() handled a no-FTS5 Python by running
'uv python install --reinstall', but WHICH Python builds a uv can
install is baked into the uv binary's download manifest. A stale uv
(e.g. 'pip install uv==0.7.20', which predates python-build-standalone
#694) only knows about pre-FTS5 builds, so --reinstall just pulls the
same FTS5-less interpreter — a no-op for FTS5. Result: 'Could not obtain
an FTS5-capable Python' and a broken session search even on the
supported installer path.

ensure_fts5() now escalates uv itself: reinstall with current uv ->
'uv self update' + reinstall (stale standalone uv) -> install a fresh
standalone uv into a temp dir and reinstall with that (externally-managed
uv that can't self-update, the reported case). Pythons live in uv's
shared store, so the fresh uv's --reinstall overwrites the stale
interpreter in place and the installer's later 'uv python find' resolves
to the FTS5-capable build.

Verified against the reporter's exact repro (ubuntu:24.04 +
pip install uv==0.7.20): Python 3.11.13 (no FTS5) -> 3.11.15 (FTS5).
2026-05-30 18:59:05 -07:00
teknium1andJezzaHehn 4ec0adebe8 fix(gateway): denylist config.yaml for media delivery (belt-and-suspenders)
Defense-in-depth on top of the EphemeralReply gate: even if a config.yaml
path reaches response text via some other path, it can never be delivered
as a native attachment. Matches existing protection for .env, auth.json,
and credentials/.

Co-authored-by: JezzaHehn <jezzahehn@gmail.com>
2026-05-30 18:58:46 -07:00
helix4u bdfba45247 fix(gateway): stop system tips from auto-uploading local files 2026-05-30 18:58:46 -07:00
Teknium b1a25404b6 perf(read_file): make compact gutter the only format; drop HERMES_READ_GUTTER (#35532)
The compact "<n>|content" gutter from #35368 is now the sole behavior.
Removes the HERMES_READ_GUTTER=padded escape hatch and its env lookup —
no legacy fixed-width path to maintain. Padding was pure token overhead
(~48% more tokens than bare content, ~16% more than compact) with no
measured accuracy gain in the original A/B.

- file_operations.py: drop env lookup + os import; gutter always f"{i}|{line}"
- tests: drop the padded env-override test; compact assertions retained
2026-05-30 14:38:30 -07:00
brooklyn! 5921d66785 fix(cli): stop OSC 11 bg probe from trapping users in a stray editor (#35441)
Over SSH the OSC 11 background-color query round-trip routinely exceeds
the 100ms read budget, so _query_osc11_background() gives up and the late
reply lands after prompt_toolkit has grabbed the tty. prompt_toolkit then
injects the OSC payload as typed text and reads its BEL terminator
(\x07 = Ctrl+G) as a keystroke — Ctrl+G is the open-external-editor
binding, dropping the user into vi with garbage and no obvious way out.

- Skip the OSC 11 probe on remote sessions (SSH_CONNECTION/CLIENT/TTY);
  fall back to COLORFGBG / env hints / the dark default.
- Restore the tty with TCSAFLUSH instead of TCSANOW so any partial/late
  reply is scrubbed from the input buffer before pt reads it.
2026-05-30 11:55:12 -05:00
Sylw3ster 6a72af044c fix(managed-gateway): keep tool availability scans off the Nous token-refresh path 2026-05-30 07:58:08 -07:00
Teknium 96643b4a52 fix(file-tools): anchor relative-path resolution to absolute base; report resolved path (#35399)
Relative paths in write_file/patch could resolve against the agent PROCESS cwd
instead of the terminal's working directory. In a git-worktree session with a
stale TERMINAL_CWD='.' (a relative base), early edits silently landed in the
MAIN checkout, verified there, and reported success — while the agent inspected
the worktree and saw nothing, misreading it as the patch tool no-op'ing.

- _resolve_base_dir(): resolution base is now ALWAYS absolute. A relative
  TERMINAL_CWD is anchored to the process cwd once, deterministically, instead
  of being left to resolve()-time cwd. Live terminal cwd stays authoritative.
- write_file/patch pass the resolved absolute path to the shell FileOps layer
  so the tool layer and shell layer can't disagree about which file is edited.
- Responses now report the absolute resolved_path and files_modified, so a
  wrong-cwd mismatch is visible on the first call.
- _path_resolution_warning(): emits a _warning when a relative path resolves
  OUTSIDE the live terminal cwd (e.g. a worktree session writing into main).

Validation: 11 new unit tests + 43 live E2E assertions (worktree routing,
mid-session cd, V4A patches, divergence warning, absolute paths, consecutive
patches); 466 existing file/path/terminal tests green.
2026-05-30 07:55:36 -07:00
Sylw3ster 0c6e133c04 perf(cli): stop eager MCP discovery from blocking agent-capable startup 2026-05-30 07:45:26 -07:00
Teknium b47cb1bbf2 feat(kanban): file attachments on tasks (#35395)
Tasks can now carry file attachments (PDFs, images, source docs) that
workers read directly — closes the gap where source material had to be
pasted as a path into the task body.

- kanban_db: task_attachments table (additive), Attachment dataclass,
  add/list/get/delete accessors, attachments_root/task_attachments_dir
  path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override)
- build_worker_context: surfaces each attachment's absolute path so the
  worker (full file/terminal tool access) reads it via read_file/pdftotext
- dashboard API: POST/GET/DELETE attachment routes (multipart upload,
  25MB cap, traversal-safe filenames, root-containment check on download)
- dashboard UI: Attachments section in the task drawer — upload button,
  list with download, per-row remove
- docs + tests (13 cases: DB accessors, REST round-trip, traversal
  rejection, collision suffixing, worker-context surfacing)

Closes #35338
2026-05-30 07:41:04 -07:00
teknium1 20d073fd0b test: update extract_local_files Windows-path test for new matching behavior
test_windows_path_not_matched asserted the pre-fix POSIX-only behavior.
The Windows drive-letter support now intentionally matches these paths,
so replace it with parametrized positive cases plus a relative-path
negative guard, mirroring tests/gateway/test_platform_base.py.
2026-05-30 07:38:03 -07:00
teknium1 1b955450e3 test: use raw docstring in test_run_tool_media_re to silence escape warning 2026-05-30 07:38:03 -07:00
Tranquil-Flow 51d165a8e7 fix(gateway): support Windows absolute paths in MEDIA tag regex and extract_local_files (#34632)
The MEDIA_TAG_CLEANUP_RE and extract_local_files path regex both used
(?:~/|/) to anchor paths, which only matches Unix-style absolute and
home-relative paths. Two additional _TOOL_MEDIA_RE patterns in run.py
had the same limitation. Windows absolute paths (C:\Users\..., D:/...)
were silently ignored, causing MEDIA directive delivery to fail.

Add [A-Za-z]:[/\\] as a third anchor alternative in all four regex
locations (base.py x2, run.py x2). Also update path separators in
extract_local_files from / to [/\\] so it can traverse Windows
directory trees.

Revert accidental + quantifier in MEDIA_TAG_CLEANUP_RE lookahead
that changed match-one to match-one-or-more (unrelated to fix).

Fixes: #34632
2026-05-30 07:38:03 -07:00
Teknium 45465b0d5d fix(gateway): never auto-pause platforms on transient network/DNS failures (#35387)
The per-platform reconnect watcher auto-paused a platform after 10
consecutive reconnect failures, setting next_retry=inf and requiring a
manual /platform resume to recover. But both pause sites only ever fire
on *retryable* failures — non-retryable errors (bad auth) already drop
out of the retry queue earlier. So a transient DNS outage that spanned
the watcher's backoff window would silently park the bot forever, even
after connectivity returned.

The watcher's own docstring already promised 'retryable failures keep
retrying at the backoff cap indefinitely' — the code contradicted it.

Remove the auto-pause from both reconnect-failure branches. Retryable
failures now retry at the 5-min backoff cap forever and self-heal once
the network recovers. The circuit breaker (_pause_failed_platform /
_resume_paused_platform) stays for manual /platform pause|resume.

Fixes #35284.
2026-05-30 07:33:34 -07:00
teknium1 cddb7283d9 fix(gateway): config.yaml path for WhatsApp/Weixin text-batch delays
Convert the salvaged text-debounce delays from HERMES_* env vars to
config.yaml (gateway.platforms.<name>.extra.text_batch_delay_seconds /
text_batch_split_delay_seconds), per the '.env is for secrets only'
policy. Adds a finite/non-negative guard so bad YAML values fall back to
the defaults instead of crashing asyncio.sleep().

- whatsapp.py / weixin.py: read delays via _coerce_float_extra(config.extra)
- update Weixin content-dedup regression test for the deferred dispatch path
- add text-debounce coverage (whatsapp + weixin): defaults, config override,
  bad-value fallback, env-var-ignored, burst-collapse, lone-message
- docs: WhatsApp + Weixin config keys
2026-05-30 07:33:15 -07:00
RedPiggy b0ce47daac feat: add text debounce batching for WhatsApp and WeChat platforms
WhatsApp and WeChat (Weixin/iLink) both deliver messages individually
without any client-side batching, so rapid multi-message bursts (forwarded
batches, paste-splits, etc.) each trigger a separate agent invocation.

This wastes tokens (redundant system prompts / context for each fragment)
and degrades UX (the user receives reply fragments instead of a single
coherent response).

Both adapters now mirror the Telegram adapter's proven text-debounce
pattern:

- _text_batch_delay_seconds / _text_batch_split_delay_seconds
  (configurable via env vars)
- _pending_text_batches dict for per-session aggregation
- _enqueue_text_event() concatenates successive TEXT messages and
  resets the flush timer
- _flush_text_batch() dispatches after the quiet period expires

Configurable via env vars:
  HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS (default 5.0)
  HERMES_WHATSAPP_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 10.0)
  HERMES_WEIXIN_TEXT_BATCH_DELAY_SECONDS (default 3.0)
  HERMES_WEIXIN_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 5.0)
2026-05-30 07:33:15 -07:00
Tekniumandpxdsgnco 234ac00937 fix(dashboard): allow insecure WS peers on explicit non-loopback binds (#35386)
The merged 0.0.0.0/:: insecure-bind fix (#35141) did not cover binding
directly to a specific non-loopback address (e.g. a Tailscale/LAN IP via
--host 100.64.0.10 --insecure). In that mode the dashboard HTML loaded but
every WebSocket upgrade was rejected by the loopback-only peer guard, so
/chat connected then silently received no data.

Generalize _ws_client_is_allowed to lift the loopback-only peer gate for
any explicit non-loopback bound host, not just the 0.0.0.0/:: wildcard.
DNS-rebinding stays blocked: _ws_host_origin_is_allowed already requires
the Host header to exactly match the bound interface for explicit binds,
mirroring _is_accepted_host on the HTTP layer.

Co-authored-by: pxdsgnco <14163800+pxdsgnco@users.noreply.github.com>
2026-05-30 07:33:02 -07:00
tekniumandsweetcornna 433bffff51 fix(cli): surface oneshot agent exceptions to stderr with rc=1
Layer an exception guard on top of the empty-response fix so a crash
inside the agent (e.g. OSError from prompt_toolkit/Vt100 when stdout is a
non-TTY pipe, per #30623) is surfaced on the real stderr with rc=1 instead
of crashing past the redirect_stderr block. KeyboardInterrupt/SystemExit
are re-raised so Ctrl-C and explicit exits still propagate.

Also map briancl2 in scripts/release.py AUTHOR_MAP for the cherry-picked
empty-response commit.

Adapts the exception-guard approach from sweetcornna's PR #33818.

Co-authored-by: sweetcornna <96944678+ymylive@users.noreply.github.com>
2026-05-30 07:31:48 -07:00
Brian LaFlamme 9fbde54b51 fix(cli): fail closed on empty oneshot responses 2026-05-30 07:31:48 -07:00
Teknium 92ad7cc62c fix(browser): recover from CDP DOM-node serialization crash in browser_console (#35385)
browser_console(expression="document.body") returned the cryptic CDP error
"Object reference chain is too long" instead of a usable result.

With returnByValue=true, Chrome deep-serializes the eval result; for a live
DOM Node/NodeList/Window that serialization overruns CDP's recursion guard
and fails the whole call with a protocol-level error (not a JS exception),
which _browser_eval surfaced raw.

- browser_supervisor.evaluate_runtime: on that specific error, retry once
  with returnByValue=false so Chrome returns the node's description string —
  the same graceful path already used for document.querySelector() results.
- browser_tool._browser_eval (CLI subprocess fallback): the subprocess can't
  retry, so convert the reference-chain error into actionable guidance
  (extract a primitive / use JSON.stringify) instead of leaking it raw.

No expression rewriting — normal evals (1+41 -> 42) are untouched.
2026-05-30 07:31:25 -07:00
Teknium 42bbd221e8 fix(compressor): strip stale handoff prefix on resume; reconcile #26290+#32787 (#35344)
A handoff persisted under an older SUMMARY_PREFIX can be inherited into a
resumed lineage. _strip_summary_prefix only matched the current/legacy
literal, so on re-compaction the old 'resume exactly from Active Task'
directive stayed embedded in the body and kept hijacking replies to new,
unrelated user messages.

- Add _HISTORICAL_SUMMARY_PREFIXES (pre-#35344 prefix) and strip/recognize
  them in _strip_summary_prefix + _is_context_summary_content so resumed
  stale handoffs are re-normalized to the current latest-message-wins prefix.
- Reconcile the overlapping Active Task template edits from the salvaged
  #26290 (reverse-signal cancellation) and #32787 (capture open questions /
  decisions, don't write None too eagerly) — both intents kept.
- Regression coverage in tests/agent/test_resume_stale_active_task.py.
- AUTHOR_MAP entries for both salvaged contributors.
2026-05-30 07:29:21 -07:00
Mathijs van den Hurk 56b8dccf25 fix(compressor): treat unanswered user questions as Active Task, not 'None'
The Active Task field in compression summaries is the single most important
field for task continuity across context boundaries. The previous template
described it narrowly as a 'task assignment' or 'request', which caused the
summary LLM to write 'None' whenever the user's most recent input was a
question, a decision request, or a discussion turn rather than an
imperative command. The assistant on the other side of the compaction then
treated the conversation as resolved and gave a generic recap instead of
answering the still-open question.

Expand the template guidance to cover:

  * explicit task assignments
  * questions awaiting an answer
  * decisions awaiting input (A vs B)
  * ongoing discussions where the assistant owes the next substantive reply

Reserve 'None' for the rare case where the last exchange was fully
resolved (e.g. user said 'thanks, that's all').

Also tighten the trailing CRITICAL instruction in the summary prompt so the
LLM cannot fall back to the old 'no imperative command → None' heuristic.

No behavioural code changes — template strings only. All 83 existing
compressor tests pass.
2026-05-30 07:29:21 -07:00
Zhipeng Li 020601d41e fix(compression): drop conflicting 'resume Active Task' directive in summary prefix
SUMMARY_PREFIX previously contained two contradictory directives:

1. "treat it as background reference, NOT as active instructions"
   "Do NOT answer questions or fulfill requests mentioned in this summary"
   "Respond ONLY to the latest user message that appears AFTER this summary"

2. "Your current task is identified in the '## Active Task' section of the
    summary — resume exactly from there."

When the latest user message contradicted Active Task (e.g. 'stop the
i18n refactor', 'never mind, look at grafana instead'), models tended to
follow (2) anyway because 'resume exactly' is a strong, unambiguous
directive — leading to repeated re-surfacing of already-cancelled work
across turns, even after explicit 'stop'/'don't keep bringing that up'
messages from the user.

This change:
- Removes the conflicting 'resume exactly from Active Task' clause.
- Makes the precedence explicit: latest user message is the single source
  of truth; it WINS on conflict; cancelled Active Task / In Progress /
  Pending User Asks / Remaining Work must be discarded entirely (no
  'wrap up the old task first').
- Names canonical reverse signals (stop, undo, roll back, never mind,
  just verify, topic change) so the model recognizes them as cancellation
  triggers, not background context.
- Updates the summarizer template instruction so the LLM doesn't
  mechanically copy a cancelled task into Active Task on the next
  compaction (it's instructed to copy the reverse signal verbatim).
- Preserves: REFERENCE ONLY framing, MEMORY.md/USER.md authority, and
  the 'don't repeat work already reflected in session state' clause.

Adds tests/agent/test_summary_prefix_semantics.py to pin invariants so
the conflict can't regress.

Tested:
- All compaction tests pass: tests/agent/test_context_compressor.py,
  tests/agent/test_context_compressor_summary_continuity.py,
  tests/run_agent/test_413_compression.py,
  tests/run_agent/test_compression_persistence.py,
  tests/run_agent/test_compression_boundary_hook.py,
  tests/cli/test_manual_compress.py — 117/117 passing.
- Tested on macOS.
2026-05-30 07:29:21 -07:00
teknium1 182739fcda test(interrupt): assert no leaked tid instead of no-op block
Follow-up on the #35309 regression test: the trailing `with _lock: pass`
asserted nothing. Replace it with a concrete assertion that
_interrupted_threads is empty after the worker exits, directly verifying
the leak the fix prevents.
2026-05-30 07:28:11 -07:00
liuhao1024 bede3cf12d fix(tools): wrap _run_tool cleanup in finally to prevent interrupt state leak
When _invoke_tool raises a BaseException (CancelledError, KeyboardInterrupt),
the cleanup code at the end of _run_tool was bypassed because it sat outside
the except block (which only catches Exception).  ThreadPoolExecutor recycles
thread IDs, so the leaked tid in _interrupted_threads poisons the next tool
scheduled on that thread — it instantly aborts with 'Interrupted'.

Move the discard + _set_interrupt(False) into a finally block so cleanup
runs regardless of how the worker exits.

Fixes #35309
2026-05-30 07:28:11 -07:00
Teknium 2b16b756a7 fix(gateway): recover model on post-interrupt turn; gate fallback status (#35381)
Empty model could reach the API on a recovery turn after stream_interrupt_abort,
failing HTTP 400 "No models provided" with no recovery — the session went
silent until the user manually re-sent (#35314).

- gateway/run.py: cache last-successfully-resolved model per session (+ a
  process-wide slot); when a fresh config read returns an empty model on a
  recovery turn, reuse the last-known-good instead of building model="".
- run_agent.py + agent/conversation_loop.py: only emit "trying fallback..."
  status when a fallback chain actually exists, so the UI stops announcing a
  fallback that will never run (also #17446).
- tests: empty-model recovery + _has_pending_fallback gate.
2026-05-30 07:28:06 -07:00
Teknium 10dec7c6dc fix(kanban): respect mobile safe areas in task detail drawer (#35378)
* fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch

Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text
files. We had no awareness of it, so: read_file surfaced a phantom
U+FEFF as the first character; patch matches against the true first
line could miss; and a write/patch round-trip silently stripped the
marker, changing the file's byte signature.

Now:
- read_file / read_file_raw strip a single leading BOM so the model
  never sees it (only on the first chunk — the marker lives at byte 0).
- patch_replace strips the BOM before fuzzy-matching (so an exact
  first-line match works) and its post-write verification compares
  BOM-stripped content.
- write_file restores the BOM when the original file had one and the
  new content doesn't, mirroring the existing line-ending preservation
  (detect on disk via a cheap `head -c 3` probe or reuse pre_content,
  re-prepend across the edit). Guards against double-BOM.

Mid-content U+FEFF is left alone (it's data there, not a file marker).

Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read
strips, write preserves, no-BOM-when-original-had-none, no-double-BOM,
patch round-trip preserves, patch matches first line through a BOM,
plus helper unit tests. 208 file-tool tests green.

* fix(kanban): respect mobile safe areas in task detail drawer

The task detail drawer is a body-level z-60 fixed overlay using
height:100vh starting at the viewport top. On mobile this puts the
drawer header behind the dashboard's fixed top bar (min-h-14, z-40)
and lets the bottom comment input sit under the browser's collapsing
nav bar.

- drawer: 100vh -> 100dvh (+ max-height:100dvh), 100vh kept as fallback
- head: padding-top honors env(safe-area-inset-top); mobile (<1024px,
  matching the lg breakpoint where the fixed bar shows) clears the
  3.5rem header
- comment-row + body: bottom padding extended with
  env(safe-area-inset-bottom) so the bottom-most element clears the
  mobile browser chrome

Mirrors the host shell idiom (100dvh + env(safe-area-inset-bottom) in
web/), and web/index.html already sets viewport-fit=cover so the insets
resolve. max()/calc() fallbacks leave desktop unchanged.

Closes #35324
2026-05-30 07:13:26 -07:00
Teknium ea6eaabd8f perf(read_file): compact line-number gutter — ~14% fewer tokens per read (#35368)
read_file's gutter used a fixed-width zero/space-padded prefix
("     1|content"). The padding is pure token overhead: measured with
cl100k on real Hermes source, the padded gutter costs ~48% more tokens
than bare content and ~16% more than a compact "<n>|content" gutter,
because the leading spaces tokenize into extra tokens on every line.

Switched the default to the compact "<n>|content" form. An A/B
(Sonnet 4.6 via OpenRouter, 2 passes, 4-task battery, every claim
verified against ground truth) showed:
  - padded  : 4/4 PASS both passes
  - compact : 4/4 PASS both passes  ← keeps line-referencing + patch
  - none    : 3/4 PASS both passes  ← dropping numbers entirely made
              the model hand-count lines and answer off-by-one (33 vs 34)

So we keep the line numbers (the model genuinely uses them to reference
lines) but drop the wasteful padding — capturing ~14% of the read-token
cost with zero measured accuracy change. Dropping numbers entirely
(the larger 33% saving) is rejected: it regresses line-referencing.

patch/fuzzy_match never consumed the gutter (they match old_string text
and compute char offsets internally), so editing is unaffected. No
downstream parser keys on the fixed-width columns. HERMES_READ_GUTTER=
padded restores the legacy format for anyone relying on alignment.

Tests: updated the 3 format assertions to the compact gutter; added an
env-override test for the legacy padded format. 209 file-tool tests green.
2026-05-30 07:01:22 -07:00
Teknium 5f84c9144a fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch (#35278)
Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text
files. We had no awareness of it, so: read_file surfaced a phantom
U+FEFF as the first character; patch matches against the true first
line could miss; and a write/patch round-trip silently stripped the
marker, changing the file's byte signature.

Now:
- read_file / read_file_raw strip a single leading BOM so the model
  never sees it (only on the first chunk — the marker lives at byte 0).
- patch_replace strips the BOM before fuzzy-matching (so an exact
  first-line match works) and its post-write verification compares
  BOM-stripped content.
- write_file restores the BOM when the original file had one and the
  new content doesn't, mirroring the existing line-ending preservation
  (detect on disk via a cheap `head -c 3` probe or reuse pre_content,
  re-prepend across the edit). Guards against double-BOM.

Mid-content U+FEFF is left alone (it's data there, not a file marker).

Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read
strips, write preserves, no-BOM-when-original-had-none, no-double-BOM,
patch round-trip preserves, patch matches first line through a BOM,
plus helper unit tests. 208 file-tool tests green.
2026-05-30 06:25:50 -07:00
sprmn24andClaude Sonnet 4.6 5a1aa9e68c fix(nous_account): add threading lock to prevent TOCTOU race on cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 06:25:43 -07:00
teknium1 44f3e51865 fix(gateway): run adapter config hooks for nested-only platform blocks
The plugin apply_yaml_config_fn dispatch loop only ran when a top-level
platform block (e.g. `discord:`) existed. Configs that defined a platform
only under `platforms.<name>` or `gateway.platforms.<name>` skipped the
hook, so `platforms.discord.extra.allow_from` never reached
DISCORD_ALLOWED_USERS. Fall back to those nested blocks when the top-level
one is absent.

Also map byquenox@gmail.com -> Que0x for the salvaged commits.
2026-05-30 05:23:55 -07:00
quen0xi 6d2727ef1c fix(discord): bridge explicit allow_from configuration to env var mapping 2026-05-30 05:23:55 -07:00
quen0xi 0bfe19ba17 fix(gateway): merge nested gateway.platforms configuration block 2026-05-30 05:23:55 -07:00
Teknium 61268ff7a9 feat(cli): add hermes prompt-size diagnostic (#35276)
Adds a 'hermes prompt-size' command that reports the fixed prompt budget
for a fresh session: system prompt total, skills index, memory, user
profile, prompt tiers, and tool-schema JSON bytes. Runs offline (dummy
credentials force the direct-construction path, no network call).

Lets users see which block dominates their per-call payload — the skills
index is often the largest single block when many skills are installed
(issue #34667). Zero model-tool footprint: it's a top-level CLI
subcommand, not an agent tool.

--platform <name> simulates a channel's platform hint; --json emits a
machine-readable breakdown.

Closes #34667
2026-05-30 02:53:42 -07:00
kshitijk4poor cbf851ae1d perf(tui): stop slow/dead MCP servers from freezing TUI startup
The 'summoning hermes…' phase blocked on gateway.ready, which ran MCP
tool discovery inline. Any configured-but-unreachable MCP server burned
its full connect-retry backoff (1+2+4s ≈ 7s) before the composer
appeared — startup went from instant to ~7.5s of dead air for anyone
with a down stdio/http server in mcp_servers.

Move discovery into a background daemon thread so gateway.ready fires
immediately; tools register into the shared registry as servers connect,
and the agent isn't built until the first prompt. Measured spawn→ready:
~7500ms → ~115ms (dead twozero_td server in config).

Also drop rich.console + prompt_toolkit off banner.py's import path
(lazy-imported inside cprint/build_welcome_banner). tui_gateway.server
imports banner only to reach the lightweight prefetch_update_check
helper; the eager rich/pt imports added ~45ms before gateway.ready for
no benefit. tui_gateway.server import: ~115ms → ~69ms.
2026-05-30 02:53:37 -07:00
teknium1 bfc4a26032 fix(tools): point email home-channel error at EMAIL_HOME_ADDRESS
The no-home-channel error for send_message derived the env var name
generically as <PLATFORM>_HOME_CHANNEL, producing EMAIL_HOME_CHANNEL for
the email platform. But gateway/config.py reads EMAIL_HOME_ADDRESS, so a
user following the error's guidance would set a variable that is never
consulted. Add a per-platform override map so the email hint names the
variable actually read; all other platforms keep the generic hint.
2026-05-30 02:39:08 -07:00
liuhao1024 d3724c0be6 fix(tools): recognize email addresses as explicit targets in send_message
When using send_message with the email platform, valid email addresses
like user@example.com were not recognized as explicit targets by
_parse_target_ref(). This caused the function to return (None, None,
False), forcing the system into channel-name resolution which has no
way to resolve a raw email address, resulting in 'No home channel set
for email' errors.

Add _EMAIL_TARGET_RE pattern and email platform handler in
_parse_target_ref() so email addresses are treated as explicit targets
and routed directly without requiring a home target configuration.
2026-05-30 02:39:08 -07:00
teknium1 622e534379 test(auxiliary): e2e routing assertions for custom-provider aux resolution
Adds two real-client tests on top of the salvaged #34783 fix:
- config-less custom:<name> endpoint routes via the carried live base_url
  (guards the #34777 symptom directly, not just the wiring)
- named custom:<name> WITH a config entry still resolves via the
  named-custom branch (regression guard against collapsing to bare custom)
2026-05-30 02:38:59 -07:00
liuhao1024 40fcb96585 fix(auxiliary): pass base_url/api_key/api_mode through set_runtime_main for custom providers
When a user configures a custom: provider (e.g. custom:openclaw-router),
set_runtime_main() only stored provider and model in process-local globals.
_resolve_auto() then had no base_url or api_key for the custom endpoint,
causing Step 1 to fail and auxiliary tasks (approval, compression, title
generation) to fall through to the aggregator chain and route to wrong
providers.

Fix: extend set_runtime_main() to accept base_url, api_key, and api_mode
keyword arguments; store them in new globals alongside the existing provider
and model; fall back to these globals in _resolve_auto() when the main_runtime
dict is empty. The call site in conversation_loop.py now passes all five
fields from the agent object.

Fixes #34777
2026-05-30 02:38:59 -07:00
Teknium 2475244ca0 fix(update/windows): robustly exclude launcher-shim ancestors from concurrent check (#35257)
hermes update on Windows still aborted with 'Another hermes.exe is running',
listing its own launcher shim(s) as concurrent instances (issues #29341,
#34795). The distlib Scripts\hermes.exe launcher spawns python.exe and waits;
detection runs in the python child, so the launcher shim shows up in
process_iter.

The prior fix walked the ancestor chain with per-hop current.parent() inside
'except: break' — the first psutil AccessDenied/NoSuchProcess (common on
Windows across session/elevation boundaries) bailed the walk early, leaving
the launcher in the candidate set and re-triggering the false positive.

- Switch to proc.parents() (whole ancestor list in one call), evaluate each
  ancestor independently so one unreadable hop never strands the launcher.
- Only exclude ancestors whose exe is itself a shim, so a genuine second
  hermes.exe under a non-Hermes parent (Desktop backend child) is still flagged.
- Message now prints a copy-pasteable 'taskkill /PID … /F' for the exact stale
  PIDs so a user who already closed everything can self-remediate.

Conservative shim-only ancestor approach credited to the parallel attempts in
PRs #29358 (xxxigm) and #31808 (jquesnelle).
2026-05-30 02:38:40 -07:00
Donovan YohanandDonovan Yohan 8bd00607dc fix(google-workspace): handle Gmail header casing case-insensitively
Normalize Gmail API message header names to lowercase before lookup so
gmail get/search/reply populate to/subject/from regardless of the casing
the message was stored with. Emit conventional MIME header casing
(To/Subject/Cc/From) on send and reply.

Fixes #34806

Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com>
2026-05-30 02:38:18 -07:00
beardthelion 6baf0016be fix(run_agent): gate concurrent checkpoint preflight on block_result (fixes #34827)
In the concurrent tool-execution path, checkpoint preflight (write_file,
patch, destructive terminal) fired BEFORE plugin guardrail block_result
was computed. A blocked write_file could still dirty checkpoint state
(doc_modified_this_turn, _last_write_file_call_id, turn_counter).

Move checkpoint preflight to AFTER block_result computation, gated on
`if block_result is None:` — matching the invariant the sequential path
already enforces.
2026-05-30 02:38:12 -07:00
teknium1 e1945ff697 test(state): cover update_session_model overwrite + getattr-guard text path
Follow-up to LengR's #35181 salvage:
- gateway text-path uses getattr(self, '_session_db', None) to match the
  picker callback path (defensive for object.__new__() gateway test pattern).
- add SessionDB.update_session_model test asserting it overwrites the
  COALESCE-pinned model and survives subsequent token updates (#34850).
2026-05-30 02:35:36 -07:00
lengr 794519c6ad fix(state): persist mid-session model switch to database
When a user switches models mid-session via /model, the gateway updates
the in-memory agent and session overrides, but the database was never
updated. The COALESCE(model, ?) in update_token_counts() only fills NULL
values, so the dashboard always showed the original model.

Fix: Add SessionDB.update_session_model() that unconditionally sets the
model column, and call it from both the interactive picker and direct
/model command paths in the gateway.

Fixes #34850
2026-05-30 02:35:36 -07:00
teknium1 c9e31a8e4b chore(release): map tuancookiez-hub for #34865 salvage 2026-05-30 02:08:36 -07:00
Tuna Dev 296fcdfa52 fix(lsp): handle Windows .cmd shims in LSP process spawn
asyncio.create_subprocess_exec cannot run .cmd/.bat files on Windows
because CreateProcess expects a valid PE executable. npm-installed LSP
servers (intelephense, typescript-language-server, etc.) ship as .cmd
shims on Windows, causing WinError 193 on spawn.

Detect .cmd/.bat extensions and wrap with cmd.exe /c before spawning.
Gated behind sys.platform == 'win32' — no code path changes elsewhere.

Fixes #34864
2026-05-30 02:08:36 -07:00
Sylw3ster 460771bf0f fix(lsp): detect Windows wrapper binaries in installer probes 2026-05-30 02:08:36 -07:00
teknium1 41decf2c4a test(mcp): import os and pytest in test_mcp_stability
The salvaged grandchild-reaping tests reference os.getpgid/os.killpg and
pytest.mark/skip/importorskip directly, but the file only imported asyncio,
signal, and unittest.mock. Add the missing imports so collection succeeds
on current main.
2026-05-30 02:08:29 -07:00
konsisumer a29d64e50c fix(mcp): reap stdio MCP grandchildren via process-group signal
The orphan reaper for stdio MCP subprocesses only tracked the direct child
PID spawned by ``stdio_client`` (e.g. ``openclaw mcp serve``). When that
wrapper itself spawned a helper (``claude mcp serve``) and then exited, the
helper reparented to ``systemd --user`` and survived shutdown.

The MCP SDK already spawns stdio children with ``start_new_session=True``,
so the wrapper is its own pgroup leader and same-pgroup descendants are
reachable via ``killpg``. Capture the pgid at spawn time and reap via
``killpg(pgid, sig)`` so reparented grandchildren are reaped alongside the
direct child, even after the wrapper itself exits. Falls back to per-pid
``os.kill`` on Windows or when no pgid was recorded.

Fixes part 2 (orphan ``claude mcp serve``) of #23799. Part 1 (per-invocation
respawn) was confirmed by the reporter to be an environmental artifact, not
a code bug.
2026-05-30 02:08:29 -07:00
teknium1 4d7ea3fd36 chore(release): map inchargeautomation-lab author email 2026-05-30 02:08:11 -07:00
teknium1andJoshua Kimbrell 2334228eca fix(update): handle pipx installs + --system fallback in _cmd_update_pip
Extends the uv-tool detection (briandevans, #29703) to cover the
remaining no-venv install layouts that hit the same uv 'No virtual
environment found' error:

- pipx-managed installs (sys.prefix under .../pipx/...) -> 'pipx upgrade',
  matching scripts/auto-update.sh (pipx-detection idea from
  inchargeautomation-lab, #29852)
- bare pip outside any venv -> 'uv pip install --system --upgrade'
- venv (launcher shim) keeps the VIRTUAL_ENV overlay from #35224 and never
  gets --system, so the install always targets the venv, not system Python

The four branches are mutually exclusive; VIRTUAL_ENV is exported only for
the uv-pip-in-venv path (uv tool / pipx upgrade ignore it).

Co-authored-by: Joshua Kimbrell <incharge.automation@gmail.com>
2026-05-30 02:08:11 -07:00
briandevans bebd4f8516 fix(cli): restrict uv-tool-install detection to running interpreter
Copilot review on PR #29703 flagged two issues with the `uv tool list`
fallback in `is_uv_tool_install`:

1. False positive: `uv tool list` returns the *machine*'s installed
   tools, not the active install. A regular pip/venv Hermes on a host
   that also has `uv tool install hermes-agent` available would be
   misclassified as a uv-tool install, and `hermes update` would
   upgrade the wrong copy.

2. Overhead: the subprocess call (up to a 15s timeout) was triggered
   even from `recommended_update_command_for_method`, which just
   computes a display string.

Restrict detection to properties of the running interpreter
(`sys.prefix` and `sys.executable` — both can carry the uv-tool layout
marker depending on entry point). Drop the `uv tool list` fallback and
the `uv_path` parameter entirely. `_cmd_update_pip` now also surfaces a
clear hint when the runtime looks like a uv-tool install but `uv` is
missing from PATH, instead of silently falling back to `python -m pip`.
2026-05-30 02:08:11 -07:00
briandevans 1bdb29d938 fix(cli): use uv tool upgrade when Hermes is a uv tool install (#29700)
Hermes installed via `uv tool install hermes-agent` lives outside any
venv. `_cmd_update_pip` previously ran `uv pip install --upgrade`, which
errors with `No virtual environment found; run uv venv ...`. The user
hits this on the very first `hermes update` after a standard
non-`--system` install with `uv` on PATH.

Add `is_uv_tool_install()` in `hermes_cli/config.py`: fast path inspects
`sys.prefix` for the standard `uv/tools/hermes-agent/` layout, falls
back to `uv tool list` for non-standard prefixes. Both the
user-facing `recommended_update_command_for_method("pip")` string and
the actual subprocess invocation in `_cmd_update_pip` now switch to
`uv tool upgrade hermes-agent` when detected. Non-tool installs and the
no-`uv` fallback keep their existing commands unchanged.
2026-05-30 02:08:11 -07:00
Teknium 39f6b6e9d2 fix(file-tools): make write_file/patch atomic (temp-file + rename) (#35252)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* fix(file-tools): make write_file/patch atomic (temp-file + rename)

write_file streamed content straight into the target via `cat > path`, so
a crash, SIGKILL, or truncated pipe mid-write left the file half-written
and corrupt. patch_replace routes through write_file, so it shared the flaw.

Now writes stream into a temp file in the SAME directory and `mv` it over
the target — a real same-filesystem rename, which is atomic on POSIX and on
every terminal backend (local/docker/ssh/modal). A failed write leaves the
original byte-intact and leaks no temp file. The existing file's mode is
preserved across the swap (stat + chmod, GNU/BSD), and content still rides
stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path.

Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering
inode-change-on-overwrite, mode preservation, failed-write-leaves-original,
no-temp-leak, special chars, and patch routing. Updated two mocks in
test_file_operations.py that keyed on the literal `cat >` write command to
key on the stdin_data behavioral signal instead. 200 file-tool tests green.
2026-05-30 02:07:50 -07:00
teknium1 6a08fd3c3f test(skills): assert restore via synced[copied], not manifest re-read
The hermetic CI env (slice 4/6) redirects HERMES_HOME, so a post-restore
_read_manifest() can resolve to an empty/redirected manifest path and return
{}. Assert on sync_skills's in-memory return value (synced["copied"]) instead,
which is the resilient signal that the skill was re-copied and is no longer in
limbo.
2026-05-30 02:05:10 -07:00
teknium1 8ae0802d59 fix(skills): make _rmtree_writable handle read-only directories, not just files
The cherry-picked fix's onerror handler chmod'd only the failing path, but
unlinking a child requires write permission on its PARENT directory. On a true
Nix-store copy (r-xr-xr-x dirs + files) rmtree still failed. Now chmod the
parent dir as well before retrying.

Also rewrites the regression test: the original asserted the helper FAILS on a
read-only dir (documenting the limitation), which is the wrong success criterion.
Split into two tests — restore succeeds on a full read-only tree (real Nix case),
and manifest is preserved when removal genuinely cannot proceed (monkeypatched).
2026-05-30 02:05:10 -07:00
annguyenNous 83a7d0b601 fix(skills): fix transaction ordering in reset_bundled_skill and handle read-only files in rmtree
Two related bugs in tools/skills_sync.py affecting Nix-store and
immutable-package installs:

**#34972 — reset_bundled_skill corrupts manifest on rmtree failure:**
The function deleted the manifest entry BEFORE attempting rmtree. If
rmtree failed (read-only files from Nix store), the function returned
early — leaving the skill in a manifest-less limbo state where future
syncs silently skip it forever.

Fix: reorder steps — attempt rmtree FIRST, only delete manifest entry
after rmtree succeeds. If rmtree fails, nothing is changed.

**#34860 — stale .bak directories after sync:**
sync_skills() called shutil.rmtree(backup, ignore_errors=True) which
silently failed on read-only files, leaving persistent .bak dirs.

Fix: add _rmtree_writable() helper that makes files writable via an
onerror callback before retrying removal. Used in both sync_skills()
backup cleanup and reset_bundled_skill().

Fixes #34972
Fixes #34860
2026-05-30 02:05:10 -07:00
liuhao1024 a57cc00081 fix(packaging): include mcp_serve in py-modules so hermes mcp serve works on pip installs
mcp_serve.py was missing from the setuptools py-modules list, causing
hermes mcp serve to crash with ModuleNotFoundError on standard pip installs.

Fixes #34871
2026-05-30 01:45:30 -07:00
Teknium 93e6a05efc feat(model-picker): group multi-endpoint providers under one row (#35227)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* feat(model-picker): group multi-endpoint providers under one row

The interactive provider pickers (hermes model, setup wizard, Telegram
/model) listed every provider slug flat, so vendors with several endpoints
(Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub
Copilot) each occupied multiple top-level rows. Now related slugs fold into
one top-level row that drills down to the specific endpoint.

- models.py: add PROVIDER_GROUPS table + group_providers() fold (display
  only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model>
  all unchanged and individually addressable).
- hermes model (main.py): group rows drill into a member sub-picker, then
  dispatch to the existing _model_flow_* unchanged. setup wizard inherits it.
- Telegram /model: new mpg:<group> callback expands to member mp:<slug>
  buttons; single authenticated member degrades to a direct button.
- Grouping is the single shared fold across all three surfaces.

Validation: 163 targeted tests pass; E2E confirms group->member->model
resolves to the correct concrete slug for all families.
2026-05-30 01:41:33 -07:00
LeonSGP43 14517ac1f5 fix(update): export launcher virtualenv to uv 2026-05-30 01:41:29 -07:00
teknium1 8e5a6854c3 fix(kanban): align recompute_ready guard with breaker's configured failure_limit
Follow-up to the budget-exhaustion recovery fix. recompute_ready's
new circuit-breaker guard resolved its effective limit from per-task
max_retries -> DEFAULT_FAILURE_LIMIT, skipping the dispatcher's
configured kanban.failure_limit. _record_task_failure resolves
max_retries -> failure_limit(config) -> DEFAULT, so the two disagreed
whenever an operator set kanban.failure_limit != 2:

- config > 2: a task could get stuck at DEFAULT(2) before reaching its
  allowed retry count.
- config < 2: a task the breaker already blocked could be auto-recovered
  back to ready, defeating the stricter limit.

Thread the dispatcher's failure_limit through dispatch_once into
recompute_ready so the guard and the breaker share one resolution order.
Updated test_circuit_breaker_block_still_auto_promotes (it asserted a
failures=5 block auto-recovers and resets the counter — that's the
pre-#35072 behavior the loop fix removes); it now exercises a below-limit
transient block, with the at-limit case covered in test_kanban_db.py.
Added two tests for the config-tier and per-task override resolution.
2026-05-30 01:40:57 -07:00
liuhao1024 6ab71d3bb4 fix(kanban): prevent infinite retry loop when worker exhausts iteration budget
recompute_ready() previously reset consecutive_failures to 0 when
auto-recovering a blocked task.  This defeated the circuit-breaker:
a task that repeatedly exhausted its iteration budget would cycle
forever (block → auto-recover with counter=0 → respawn → budget
exhausted → block → …) with no signal to the operator.

Fix: don't auto-recover tasks whose consecutive_failures has reached
the effective failure limit (per-task max_retries or
DEFAULT_FAILURE_LIMIT).  The counter is also preserved across
recovery so the breaker can accumulate across cycles.

Fixes #35072
2026-05-30 01:40:57 -07:00
teknium1andliuhao1024 c70dca3a88 fix(kanban): rebuild legacy TEXT-PK tables to INTEGER AUTOINCREMENT on open
Legacy kanban boards (pre-AUTOINCREMENT schema) crashed the gateway
notifier on every tick — int(None) on a NULL id in unseen_events_for_sub
— silently losing all kanban notifications. CREATE TABLE IF NOT EXISTS
skips existing tables regardless of schema and _add_column_if_missing
only adds columns, so neither could fix a drifted primary-key type.

_rebuild_drifted_tables() detects the legacy shape via PRAGMA table_info
and rebuilds task_events/task_comments/task_runs (TEXT PK -> INTEGER
AUTOINCREMENT) and kanban_notify_subs.last_event_id (TEXT/NULL -> INTEGER
NOT NULL DEFAULT 0), preserving data. The whole pass is one transaction
so an interruption can't leave a table half-renamed, and recreates every
index DROP TABLE would otherwise take down (including idx_events_run).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-05-30 01:40:49 -07:00
teknium1andBROCCOLO1D 16882cfded refactor(tui): simplify base64 clipboard write to a stdin flag
The per-entry psScript callback was identical for every PowerShell entry,
so the function-valued union member added structure without behavior. Collapse
WriteCmd to a plain stdin boolean and apply the one shared base64 script in the
write loop. Document the CP936 root cause inline.

Co-authored-by: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com>
2026-05-30 01:40:44 -07:00
annguyenNous 64998fa93e fix(tui): use base64 encoding for PowerShell clipboard writes to preserve UTF-8
When writing text to the clipboard via PowerShell (WSL2 and native Windows),
the previous implementation piped text through stdin using `Set-Clipboard
-Value $input`. PowerShell reads stdin using the Windows system's default
ANSI code page (e.g. CP936 for Chinese Windows), causing all non-ASCII
characters (CJK, emoji, accented) to become garbled.

Fix: encode the text as base64 in Node.js and pass it as a command argument.
PowerShell decodes it from base64 using explicit UTF-8, bypassing the code
page issue entirely.

Fixes #35107
2026-05-30 01:40:44 -07:00
Teknium b4cf114f68 fix(vision): fail fast on non-retryable image download errors (#35221)
_download_image() wrapped every download attempt in a blanket
`except Exception` and retried 3x with 2s/4s/8s backoff regardless of
cause. A 404/403 image URL would never resolve on retry, so it just
burned up to 6s of wall-clock + extra GETs before failing — inflating
latency for a deterministic failure (issue #32296, umbrella #35114).

Add _is_retryable_download_error(): 4xx client errors (except 429),
website-policy PermissionError, and too-large/SSRF ValueError now raise
on the first attempt. 429, 5xx, and unclassified network errors stay
retryable. Removed the now-unreachable fall-through branch since the
loop always returns on success or re-raises on the final/terminal attempt.
2026-05-30 01:40:39 -07:00
kshitij e481b15333 Merge pull request #35216 from kshitijk4poor/fix/agents-nudge-single-delegate
fix: surface /agents nudge for single-delegate fan-out (TUI + CLI)
2026-05-30 00:57:15 -07:00
kshitijk4poor 9d2571c86a fix: surface /agents nudge while delegate_task is in-flight (TUI + CLI)
The subagent spawn-observability overlay added a `(/agents)` hint, but
only on the standalone "Spawn tree" panel, gated behind `!inlineDelegateKey`
— it never showed for a single delegate_task call, and only appeared once
subagents had already registered. A nudge that arrives at the end (or only
after spawn) is useless for the actual goal: letting users open the live
monitor *while* delegation is running.

Surface it the moment delegation starts, on both surfaces:

TUI (ui-tui/src/components/thinking.tsx)
- Show `(/agents)` on any "Delegate Task" tool group as soon as it appears
  (in-flight, before any subagent registers), not gated on subagents
  already existing. Same `startsWith('Delegate Task')` predicate already
  used for delegateGroups.

CLI (agent/tool_executor.py)
- Append `· /agents to monitor` to the delegate spinner label, which is
  displayed for the full duration of the delegate_task call. The previous
  attempt put the hint on the completion line (get_cute_tool_message),
  which only renders after the call finishes — reverted.

TUI tsc clean (pre-existing execFileNoThrow type errors unrelated);
subagentTree 35/35; display.py reverted to upstream.
2026-05-30 13:22:45 +05:30
Teknium bb79bcde61 fix: detect pyproject.toml / __init__.py version drift in hermes doctor (#35142)
A git conflict resolution (reset --hard or merge) can revert
hermes_cli/__init__.py to a stale __version__ while pyproject.toml stays
current, so 'hermes --version' silently reports the wrong version. Nothing
cross-checked the two files.

Add a version-consistency check to the doctor 'Python Environment' section:
reads the [project] version from pyproject.toml and compares it to
hermes_cli.__version__. Reports OK when they match, fails with a re-sync
hint when they drift, and is a silent no-op for installed wheels where
pyproject.toml isn't present.

Closes #35070
2026-05-30 00:32:05 -07:00
teknium1 e5765e61fa chore(release): map wei.chen.coder@gmail.com -> wenchengxucool 2026-05-30 00:30:55 -07:00
weichengxu 84ee80eb5d feat: set process title to 'hermes' in ps/top/htop
Adds _set_process_title() in hermes_cli/main.py, called first thing in
main(). Tries setproctitle (optional) for a full ps-args rewrite, then
falls back to ctypes prctl(PR_SET_NAME) on Linux / pthread_setname_np on
macOS. No-op on Windows and on any failure. No new dependency: the
setproctitle path is best-effort via ImportError guard.

Fixes #35108
2026-05-30 00:30:55 -07:00
teknium1 17103a1f11 chore: add SeaXen to AUTHOR_MAP for salvaged PR #33278 2026-05-30 00:23:44 -07:00
SeaXen e8076c1ebe fix(dashboard): allow chat websockets on insecure public bind
Allow non-loopback websocket peers when the dashboard is explicitly exposed with --host 0.0.0.0/:: and --insecure.

This fixes the failure mode where /chat rendered over LAN but /api/ws and /api/events were rejected with HTTP 403, leaving the embedded TUI chat disconnected.

Add regression coverage for the insecure public bind case in the dashboard websocket auth tests.
2026-05-30 00:23:44 -07:00
Max Hsu 636ff636d7 fix(agent): strip schema-foreign keys from max-iterations summary request (#34436)
The max-iterations summary path (`handle_max_iterations`) hand-builds its
message list and calls `chat.completions.create()` directly, bypassing
`ChatCompletionsTransport.convert_messages()`. It only popped
("reasoning", "finish_reason", "_thinking_prefill"), so `tool_name` (SQLite
FTS bookkeeping), the `codex_*` reasoning carriers, and other internal
`_`-prefixed scaffolding leaked to the wire.

Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go, Mistral,
Moonshot/Kimi) reject these with HTTP 400 "Extra inputs are not permitted,
field: 'messages[N].tool_name'", so a long tool-using session that exhausts
the iteration budget fails to summarise instead of returning the result.

Mirror convert_messages() in this path: also drop tool_name,
codex_reasoning_items, codex_message_items, and every `_`-prefixed key.
Copy-on-write is already in place, so internal history keeps the fields for
FTS / Codex-fallback.

Adds a regression test to TestHandleMaxIterations asserting the summary
request carries none of the schema-foreign keys (fails on main, passes here).
2026-05-30 00:22:53 -07:00
Teknium c1b2d0917f fix(cli): don't treat any container as the Docker image for updates (#35139)
detect_install_method() returned "docker" for any container (is_container()),
before the .git check. Both supported installs already self-identify via the
.install_method stamp read first: the curl installer (scripts/install.sh)
git-clones and stamps "git"; the published nousresearch/hermes-agent image
stamps "docker" at boot via docker/stage2-hook.sh. An unsupported manual
install dropped into a container has no stamp, so the bare container check
hijacked it to "docker" and 'hermes update' bailed with the docker-pull
guidance.

Drop the redundant is_container() -> docker fallback. Unstamped installs now
fall through to the .git/pip checks like any off-path install; both supported
paths are unaffected because the stamp wins first.

Fixes #34397.
2026-05-30 00:22:46 -07:00
kshitij 8738cb92c3 Merge pull request #34704 from kshitijk4poor/feat/tui-agents-nudge
feat(tui): nudge toward /agents dashboard when delegation starts
2026-05-30 00:01:59 -07:00
kshitijk4poor 5a72e82fd8 feat(tui): nudge toward /agents dashboard when delegation starts
The TUI already ships a rich /agents spawn-tree dashboard (live tree,
timeline, per-child tokens/cost/files/tools, kill/pause), but nothing
surfaced it — during delegation the transcript stayed quiet and users
had to already know to type /agents.

Drop a one-time transient activity hint ("subagents working · /agents
to watch live") the first time a turn starts delegating, matching the
existing "· /logs to inspect" house style. Guards keep it unobtrusive:

- fires at most once per turn (resets on message.start)
- silent when the /agents overlay is already open
- gated by display.tui_agents_nudge (default true)

Hooked on subagent.start, not subagent.spawn_requested: the delegate
progress callback in tools/delegate_tool.py only relays start/complete
to the gateway and drops spawn_requested, so start is the first
delegation event the TUI reliably receives. spawn_requested is wired
too for the future case, guarded once-per-turn.

Adds the display.tui_agents_nudge config default and gatewayTypes entry.
2026-05-30 12:26:36 +05:30
kshitijk4poor 7b0915037c test: remove low-value model-catalog mirror tests
These tests asserted that hardcoded curated model lists/constants still
contained specific model strings (e.g. 'glm-5' in provider_model_ids('zai'),
exact context-length values per model key, PROVIDER_TO_MODELS_DEV entries).
They mirror a constant rather than exercise logic, so they only ever break
when models are added/retired and never catch a real bug.

Removed 22 such functions across 7 files (149 deletions, 0 additions).
Behavioral siblings are kept: live-catalog-wins, fallback ordering,
substring/longest-match resolution, normalization, credential discovery,
and probe-tier stepping all still tested.
2026-05-29 23:45:05 -07:00
Teknium 0437137fff security: pin patched Starlette (>=1.0.1) for CVE-2026-48710 BadHost (#35118)
Starlette < 1.0.1 is affected by CVE-2026-48710 ("BadHost", CWE-444).
The HTTP Host header was not validated before being used to rebuild
`request.url`, so a malformed Host could make `request.url.path` desync
from the raw ASGI path the router actually dispatched. Middleware and
endpoints that apply path-based authorization off `request.url` (rather
than `scope["path"]`) can therefore be bypassed.

Hermes pulls Starlette transitively, never directly:
  - [web]          -> fastapi==0.133.1  (starlette>=0.40.0, no upper bound)
  - [mcp]          -> mcp==1.26.0 + sse-starlette (starlette>=0.27 / >=0.49.1)
  - [computer-use] -> mcp==1.26.0
  - [dev]          -> mcp==1.26.0

A fresh resolve landed starlette 0.52.1 — vulnerable. With no upper
bound on the transitive specs, pip/uv could resolve any pre-1.0.1
release on a fresh install.

Fix: pin starlette==1.0.1 directly in every extra that exposes a
Starlette-backed server surface, regenerate uv.lock (only starlette
moves: 0.52.1 -> 1.0.1, hash-verified), and mirror the pin in the
lazy-install map (tools/lazy_deps.py `tool.dashboard`) so `hermes`
on-demand dashboard installs can't re-resolve a vulnerable version.

1.0.1 is the advisory's named fix floor and the oldest patched release
(more bake time than 1.1.0/1.2.0, which are days old); it satisfies
every carrier constraint and our requires-python>=3.11.

Scope note: this is a dependency-level fix complementing the
application-layer Host-header validator added in #34162
(`hermes_cli/web_server.py` `_is_accepted_host`). Defense in depth at
both the framework and app layers.

Guards: two invariant tests in tests/test_packaging_metadata.py assert
every server-surface extra pins starlette and that pyproject + uv.lock
both resolve >= the 1.0.1 CVE floor — a dropped pin or stale lock fails
in CI instead of shipping the bypass.

Closes #35067
2026-05-29 23:23:54 -07:00
ErosikaandBROCCOLO1D 827ce602db fix(honcho): harden self-hosted setup paths
Self-hosted Honcho setup had four sharp edges:

- local/cloud URLs ending in /vN double-prefixed by the SDK (/v3/v3/... 404)
- authenticated local servers had no setup prompt for a JWT/bearer token
- profile-derived host keys could be dot-containing workspace IDs Honcho rejects
- memory-provider config files with API keys written world-readable per umask

This keeps existing behavior but makes those paths safer:

- strip a trailing /vN version segment from any configured baseUrl before SDK
  init (the SDK's route builders always prepend their own version prefix);
  auth-skipping stays loopback-only
- add an optional local JWT/bearer prompt in honcho setup, stored under
  hosts.<host>.apiKey
- derive new profile host keys with underscores, still reading legacy
  hermes.<profile> blocks
- write memory-provider config files atomically with 0600 via a shared
  utils.atomic_json_write(mode=) arg (honcho/hindsight/mem0/supermemory)
- skip honcho.json parsing in gateway cache-busting unless Honcho is the active
  memory provider; memoize by honcho.json mtime when active
- bust the gateway agent cache on memory.provider change
- add a hermes memory setup <provider> one-liner so fresh installs can configure
  a named provider without the picker (the per-provider hermes <provider>
  subcommand only registers once that provider is active)

Closes #20688, #29885, #26459, #30246, #33382, #32244.

Co-authored-by: BROCCOLO1D
2026-05-29 22:29:48 -07:00
Siddharth Balyan aa32edcac5 fix(setup): write config for image_gen and video_gen in apply_nous_managed_defaults (#35109)
apply_nous_managed_defaults() was adding image_gen and video_gen to the
'changed' return set without writing any config values.  The caller
(tools_command first_install flow) uses 'changed' to skip manual
configuration, so these tools ended up in platform_toolsets but with no
video_gen.provider, video_gen.use_gateway, or image_gen.use_gateway in
config.yaml.

At runtime the FAL plugin's is_available() returned False because there
was no FAL_KEY and no use_gateway config — the tool never loaded despite
being 'enabled' in the toolset list.

For image_gen this was a latent bug masked by the gateway offer prompt
(prompt_enable_tool_gateway) running earlier in the setup flow and
writing image_gen.use_gateway=True via apply_gateway_defaults().  But if
the user skipped the gateway offer, image_gen would silently break the
same way.

For video_gen (added in PR #33259) the bug was always hit because the
gateway offer ran before the user checked video_gen in the toolset
checklist.

Fix: write provider/use_gateway config values before adding to 'changed',
matching the pattern used by web, tts, and browser.
2026-05-30 03:45:12 +00:00
teknium1 a7421dc7d2 fix(session): point no-FTS5 warning at the supported install
When FTS5 is missing the warning now explains the likely cause (an
unsupported / pip-managed Python whose bundled SQLite lacks FTS5) and
links the supported install at hermes-agent.nousresearch.com, instead
of just logging the raw error.
2026-05-29 20:11:07 -07:00
teknium1 4fa20f9a8b fix(install): ensure the uv-managed Python ships SQLite FTS5
uv's python-build-standalone distributions only gained FTS5 in mid-2025
(#694). A stale interpreter already in uv's store — which `uv python find`
reuses without checking — can lack it, leaving the supported install with
a SQLite that can't create the FTS5 virtual tables hermes_state.py needs
for full-text session search ("no such module: fts5").

check_python now probes the resolved interpreter for FTS5 and, if missing,
reinstalls the latest patch for $PYTHON_VERSION (which has FTS5) and
re-resolves. If an FTS5-capable Python still can't be obtained (offline,
pinned env), it warns and continues — Hermes degrades gracefully and only
disables session search. No bundled second SQLite, no user action.
2026-05-29 20:11:07 -07:00
teknium1 97ecfa0fc4 fix(session): extend no-FTS5 degradation to the trigram CJK index
The salvaged contributor commit guarded only messages_fts. Current main
also creates a second virtual table, messages_fts_trigram (CJK substring
search), whose CREATE VIRTUAL TABLE ... USING fts5 still raised
"no such module: fts5" on builds without FTS5 — re-crashing SessionDB
init. Wrap the trigram setup with the same guard, and broaden the test's
no-fts5 mock to fail BOTH tables so the regression test actually
exercises a faithful no-FTS5 build.
2026-05-29 20:11:07 -07:00
LeonSGP43 5ad2b4c6da fix(session): degrade gracefully when SQLite lacks FTS5 2026-05-29 20:11:07 -07:00
Teknium 860cf28dab docs: clarify compression threshold is derived from the main model's context window (#35099)
The compression threshold is threshold × context_length where context_length
is the MAIN agent model's window, not the auxiliary/summary model's. On a
262,144-token model at the default 0.50 the threshold is 131,072 — close to a
common 128K figure by coincidence of the percentage, which has led to confusion
that the auxiliary model's context limit is the trigger. Add a note preempting
that misreading and pointing to the separate summary-model-context constraint.
2026-05-29 19:59:04 -07:00
teknium1 fb0ab27649 fix(agent): register explainer config key + shorten footer prefix
Follow-up to the salvaged #34452 turn-completion explainer:
- Register display.turn_completion_explainer: True in DEFAULT_CONFIG so the
  setting is discoverable, matching the file_mutation_verifier precedent.
- Shorten the repeated footer prefix from 'Turn ended without a usable
  reply: ' to 'No reply: ' so the 10 reason variants don't all open with
  the same 8-word boilerplate.
- Update the 7 assertions that referenced the old prefix.
2026-05-29 19:23:05 -07:00
Bartok9 de6d6023d7 test(run_agent): align test_dict_tool_call_args with explainer suffix
PR #34470 adds an explainer suffix to abnormal turn endings (e.g.
max_iterations_reached) so users see why the response is short instead
of receiving a bare/blank reply. test_tool_call_validation_accepts_dict_arguments
runs the agent at max_iterations=3 which hits the explainer path; the
existing strict-equality assertion (== "done") no longer matches once
the suffix is appended.

Switch the assertion to .startswith("done") so the test continues to
verify that the models actual text survives intact while leaving the
explainer suffix wording owned by conversation_loop (where it belongs).

Test now passes (1 passed in 0.88s).
2026-05-29 19:23:05 -07:00
Bartok9 59b0ea98c8 fix(agent): explain abnormal turn endings instead of blank/partial reply
When a turn ends abnormally after substantive tool calls (empty content
after retries, a partial/truncated stream, exhausted retries, or an
iteration/budget limit), the CLI/TUI response area was left blank or
showed only a fragment (e.g. "The") with no consolidated reason. The
internal turn_exit_reason values (empty_response_exhausted,
partial_stream_recovery, etc.) were never surfaced to the user.

Add a turn-completion explainer that mirrors the existing file-mutation
verifier footer: at turn end, map an abnormal turn_exit_reason to a
short, actionable message and either replace the bare "(empty)"
sentinel or append the reason after a partial fragment. Normal
text_response exits (e.g. a terse "Done.") stay quiet.

Gated by display.turn_completion_explainer (default on) with
HERMES_TURN_COMPLETION_EXPLAINER env override, matching the
file-mutation verifier seam.

Closes #34452
2026-05-29 19:23:05 -07:00
Teknium 897f9533ed fix: keep CLI context display in sync with preflight token estimate (#35079)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* fix: keep CLI context display in sync with preflight token estimate

The status bar reads compressor.last_prompt_tokens, which only updates
from a successful API response. When loaded history is oversized but
compression no-ops (e.g. the auxiliary summary model times out), no fresh
usage arrives and the bar stays frozen at the old, smaller value while the
preflight estimate reports a much larger number — looking permanently out
of sync (reported: 74.4K display vs ~144,669 preflight).

Seed last_prompt_tokens with the fresh preflight estimate (upward-only, so
a real usage figure is never clobbered and a successful compression's
downward correction still wins). Display-only; no behavioral change to
compression, caching, or the agent loop.
2026-05-29 19:21:15 -07:00
teknium 9d4c81130a fix(gateway): name what the /status token number actually is
Sharpen the label from 'Session usage (cumulative)' to 'Cumulative API
tokens (re-sent each call)'. The number is real provider-reported usage
summed across every API call in the session — not context size. In an
agentic loop the same context is re-sent each iteration, so a one-hour
tool-heavy session legitimately reaches tens of millions of tokens. The
new label explains the magnitude so users stop reading it as a bug or as
a total across all sessions.
2026-05-29 19:14:37 -07:00
helix4u 2259c15e4d fix(gateway): clarify status session usage label 2026-05-29 19:14:37 -07:00
Bartok9 45bc65abbe fix(gateway): drop outbound silence-narration messages pre-send
Hallucinated 'silence' tokens (*(silent)*, _silent_, the bare '.', '...',
'silent', no response/reply, the mute emoji) are emitted when a persona has
nothing actionable to say. In bot-to-bot channels the receiving bot mirrors
the token back, creating a tight loop that burns API tokens and can crash a
model with 'no content after all retries'. SOUL.md/prompt rules drift across
providers and have already failed in practice, so add a substrate-level guard.

_deliver_to_platform now drops a message whose finalized content is only a
silence-narration token, logs a WARNING with platform/chat_id/truncated
content, and returns {success: True, filtered: 'silence_narration',
delivered: False} instead of calling the adapter. Single chokepoint covers
every platform adapter; the regex is anchored start/end with a 64-char guard
so prose like 'Silence is golden — here is the plan...' or 'Silent install
completed' is never dropped. Local/file delivery is a separate path and is
left untouched. Opt out via gateway.filter_silence_narration: false or the
HERMES_FILTER_SILENCE_NARRATION env override (env wins when set).

Closes #34616
2026-05-29 19:06:05 -07:00
teknium1 9dbc3722ae test(compression): fix StopIteration in large-rough-growth preflight test
The rough-estimate mock supplied only 2 side_effect values but the
conversation loop calls estimate_request_tokens_rough a third time for
the post-response real-token estimate, exhausting the iterator. Use a
callable side_effect that returns 125k once (to fire preflight) then
sub-threshold values, independent of call count.
2026-05-29 19:05:03 -07:00
helix4u e38b0b55d1 fix(compression): avoid repeat preflight compaction from rough estimates 2026-05-29 19:05:03 -07:00
Teknium 04de307d62 fix(cli): repaint input area after inline /steer and /model submit (#34839)
handle_enter dispatches /steer and /model inline on the UI thread while
the agent is running, calling buffer.reset() then returning. Unlike every
other early-return branch in the handler, these two skipped
event.app.invalidate(). process_command() prints through patch_stdout
(scrolls output above the prompt without redrawing the input line), so the
just-cleared input area could keep showing the submitted '/steer <text>'
until an unrelated redraw fired — looking unsent and inviting an accidental
re-submit.

Add event.app.invalidate() after reset in both inline branches to match
the sibling branches. AST regression test pins the invariant: every
reset-then-return branch in handle_enter must invalidate first.

Fixes #34569
2026-05-29 19:04:40 -07:00
Teknium bcc8301000 Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' (#35048)
Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).
2026-05-29 17:49:15 -07:00
Bartok9 54aa4db1de fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall
The POSIX installer drops node/npm/npx symlinks in ~/.local/bin pointing
into $HERMES_HOME/node and prepends ~/.local/bin to PATH, shadowing an
existing nvm. Uninstall removed the hermes wrapper but left these behind,
so the user's default node/npm/npx stayed redirected after uninstall.

Add remove_node_symlinks() and call it from run_uninstall. It removes
~/.local/bin/{node,npm,npx} only when each is a symlink resolving into the
current Hermes home's node dir, so a link the user repointed at nvm or a
real binary is never touched. Handles dangling links too.

Closes #34536
2026-05-29 17:24:38 -07:00
Teknium 2062a84000 fix(auxiliary): stop capping output with max_tokens by default (#34530) (#34845)
* fix(auxiliary): stop capping output with max_tokens by default

Auxiliary LLM calls (compression, titles, vision, etc.) no longer send
max_tokens on the OpenAI-compatible chat-completions path. Most providers
treat an omitted max_tokens as "use the model max", which is what we want;
an explicit cap only risks truncation or a wire-format 400.

This was surfaced by GitHub Copilot / GPT-5 (#34530): those models reject
max_tokens and require max_completion_tokens, so compression 400'd and fell
back to a static context marker. Omitting the param sidesteps that quirk
(and ZAI vision's error 1210) entirely.

The Anthropic Messages wire (MiniMax + /anthropic endpoints) keeps
max_tokens because it is a mandatory field there.

* test(auxiliary): update temperature-retry assertions for omitted max_tokens

The temperature-retry tests asserted retry_kwargs["max_tokens"] == 500 on an
api.openai.com endpoint. Now that auxiliary calls omit max_tokens on
OpenAI-compatible endpoints (#34530), that key is absent. Assert it's absent
in both first and retry kwargs and use model as the survives-the-retry witness.
2026-05-29 17:24:30 -07:00
Teknium f9daa4a41d fix(deps): declare setuptools in dev extra for packaging tests (#34851)
* fix(deps): declare setuptools in dev extra for packaging tests

tests/test_packaging_metadata.py imports `from setuptools import
find_packages` at module scope to validate package discovery against
the live tree. setuptools was being picked up ambiently from the CI
runner image, but recent ubuntu-latest images no longer ship it in the
test venv, so collection fails with ModuleNotFoundError on every PR.

Declare setuptools==82.0.1 in the dev optional-dependencies so `.[all,dev]`
installs it explicitly rather than relying on the runner environment.

* test(packaging): skip packaging-metadata tests when setuptools absent

Belt-and-suspenders alongside declaring setuptools in [dev]: guard the
module-level `from setuptools import find_packages` with
pytest.importorskip so a runner missing setuptools SKIPS these checks
instead of erroring out collection for the entire test shard.

* chore(deps): sync uv.lock for setuptools dev dependency
2026-05-29 17:24:23 -07:00
Teknium 689ef5e233 feat(cli): warn on unsupported pip installs + fix stale update-check cache (#34491) (#34846)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* feat(cli): warn on unsupported pip installs + fix stale update-check cache after pip upgrade

Banner now shows a yellow warning when detect_install_method() == 'pip':
'pip install hermes-agent' isn't the supported install path (it exists on
PyPI for internal/CI reasons), so updates and issue support don't behave
correctly. Reuses existing install-method detection; warn, never block.

Also fixes #34491: check_for_updates() keyed its 6h cache only on ts+rev.
On the pip path (no HERMES_REVISION), rev is always None, so a
'pip install --upgrade' changed VERSION but left the cache valid — the
stale 'N commits behind' count survived the upgrade. Cache now also keys
on the installed VERSION and invalidates on mismatch.
2026-05-29 13:30:28 -07:00
teknium1 bb50825716 chore(release): map annguyenNous to AUTHOR_MAP
Clears the check-attribution CI gate on PR #34468 — the contributor's
noreply email was unmapped.
2026-05-29 13:29:34 -07:00
annguyenNous 9f5afc7636 fix(mcp): widen isinstance check to BaseException for CancelledError
asyncio.gather(return_exceptions=True) captures CancelledError as a
BaseException value. The previous isinstance(result, Exception) check
missed CancelledError, silently dropping it without logging.

Since Python 3.9, CancelledError is a BaseException subclass (not
Exception). This one-line change ensures all failure types from MCP
server connections are properly logged.

Fixes NousResearch/hermes-agent#34443
2026-05-29 13:29:34 -07:00
teknium1 4fd8521e44 test(tui-gateway): isolate completion_queue in poller requeue test
test_notification_poller_requeues_when_busy drained and reused the
process-global process_registry.completion_queue, so a concurrent test
in the same xdist worker could put/get on the shared singleton mid-run
and empty the event the poller requeues — flaking 'assert not
completion_queue.empty()' under parallel CI load only.

Monkeypatch a fresh Queue onto the singleton for the test's duration so
nothing external can interleave. The poller reads completion_queue by
attribute at runtime, so the isolated queue is what it operates on.
monkeypatch restores the original on teardown. Verified immune: 50/50
passes under a background thread hammering the global queue.
2026-05-29 13:29:24 -07:00
Bartok9 edfdc77664 fix(cli): resume the selected chat when a bare number follows /resume
A bare `/resume` printed the recent-sessions list but armed no selection
state, so typing just `3` on the next line was sent to the agent as chat
instead of resuming session #3. `/resume 3` worked, but the natural
list-then-pick flow did not.

Arm a one-shot pending-resume prompt when bare `/resume` shows the list,
and consume the next bare numeric input as the selection (out-of-range is
reported, non-numeric/other commands disarm it). Resolves against the same
_list_recent_sessions(limit=10) list used everywhere else.

Closes #34584.
2026-05-29 13:29:24 -07:00
Teknium 3a2c03061c fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted (#34841)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted

PyPI quarantined mistralai on 2026-05-12 after the malicious 2.4.6
release (Mini Shai-Hulud worm). 2.4.6 has since been removed from the
registry and clean releases resumed (2.4.7 2026-05-25, 2.4.8 2026-05-28).
This rolls back the blanket runtime ban so Voxtral STT + TTS work again,
following the restoration checklist the repo left in pyproject.toml.

Verified against the real SDK: 2.4.8 keeps the import path the code uses
(from mistralai.client import Mistral) and the audio.transcriptions.complete
/ audio.speech.complete surfaces.

Changes:
- pyproject.toml: re-add mistral extra pinned to mistralai==2.4.8; left
  OUT of [all] per the 2026-05-12 lazy-install policy (one quarantined
  release must not break fresh installs). uv.lock regenerated.
- tools/lazy_deps.py: add stt.mistral / tts.mistral entries so the SDK
  lazy-installs on first use (matches edge / elevenlabs).
- tools/transcription_tools.py: restore explicit-provider gate
  (_HAS_MISTRAL + key) and auto-detect entry (local>groq>openai>mistral>xai);
  _transcribe_mistral lazy-installs before import.
- tools/tts_tool.py: dispatcher routes back to _generate_mistral_tts;
  _import_mistral_client lazy-installs the SDK.
- hermes_cli/tools_config.py, hermes_cli/web_server.py: un-hide Mistral
  from the TTS provider picker and dashboard STT options.
- hermes_cli/security_advisories.py: KEEP the shai-hulud-2026-05 advisory
  (module policy forbids removal) — it is scoped to 2.4.6 only, so it
  still warns anyone with the poisoned build cached and never fires on
  2.4.8. Summary note updated to reflect the un-quarantine.
- tests: revert the disabled-behavior assertions added by the ban commit
  back to routing/positive expectations; add mistral to the
  lazy-installable-extras-excluded-from-[all] contract.

Reported by @SkYNewZ (#34503).

Validation: 189 targeted STT/TTS/lazy_deps/metadata tests pass; E2E with
the real mistralai 2.4.8 SDK routes both STT and TTS to mistral.
2026-05-29 13:24:12 -07:00
781604ce4c fix(gateway): unify MEDIA: extraction extension set + close the unknown-ext black hole (#34517) (#34844)
MEDIA:<path> tags for .md/.json/.yaml/.xml/.html and other document
extensions were silently dropped. extract_media() carried a narrow
extension allowlist that omitted them, while extract_local_files()
had a broad one. The dispatch sites then ran an unconditional
re.sub(r'MEDIA:\\s*\\S+', '') that stripped the tag from the body even
when extract_media had not matched it — so extract_local_files (broad
list) ran on text where the path was already gone, and the file was
delivered by neither path.

- Add MEDIA_DELIVERY_EXTS in gateway/platforms/base.py as the single
  source of truth; extract_media and extract_local_files both derive
  their extension set from it (no more drift).
- Replace the loose MEDIA cleanup at the non-streaming dispatch site
  (base.py) and the streaming consumer (stream_consumer.py) with the
  shared, extension-anchored MEDIA_TAG_CLEANUP_RE. A MEDIA: tag with an
  unknown extension is left in the body so the bare-path detector can
  still pick it up instead of being black-holed.
- Chain cleaned text through extract_media -> extract_images ->
  extract_local_files in run.py's post-stream media delivery (it was
  dropping the cleaned text and rescanning raw text with MEDIA: tags).
- Regression tests covering both halves: previously-dropped extensions
  now extract, and unknown-ext paths survive the cleanup.

Consolidates the MEDIA extension-allowlist PR cluster.

Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: banditburai <123342691+banditburai@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-05-29 13:24:01 -07:00
teknium1 0dc0c5ea6b chore: add AUTHOR_MAP entry for sweetcornna
Maps the cherry-picked commit's noreply email to the GitHub login so the
release attribution / CI author check passes.
2026-05-29 13:22:54 -07:00
Bartok9 3845d86b93 fix(cron): restore jobs.json emptied by config migration on update
Config-version migrations have been observed to leave cron/jobs.json
valid-but-empty after `hermes update`, silently dropping every scheduled
job (#34600). The existing malformed-shape guards in cron/jobs.py don't
catch this because {"jobs": []} is valid JSON.

Add restore_cron_jobs_if_emptied() as a post-migration safety net: if the
live cron/jobs.json now has zero jobs while the pre-update snapshot held
one or more, restore the snapshot copy in place and warn loudly. The
check is conservative — it only restores on unambiguous evidence of loss
(snapshot had jobs, live file readable-and-empty), so a user who genuinely
cleared their jobs is never second-guessed and an unreadable live file is
left untouched so real corruption still surfaces.

Wired into _cmd_update_impl after migrate_config(), reusing the existing
pre-update quick snapshot (which already captures cron/jobs.json).

Closes #34600
2026-05-29 13:22:54 -07:00
Cornna d473e7c938 fix(cron): exclude jobs.json registry from disk-cleanup pattern
Closes #32164
2026-05-29 13:22:54 -07:00
Teknium 91b174038c fix(feishu): bound _chat_locks with LRU eviction (#34836)
The Feishu adapter stored one asyncio.Lock per chat_id in a plain dict
with no upper bound, so a long-running gateway that saw many distinct
chats grew _chat_locks without limit. Port the LRU-eviction pattern
already used by the yuanbao adapter: OrderedDict + move_to_end on access,
CHAT_LOCK_MAX_SIZE cap (1000), and eviction that skips currently-held
locks (falling back to dropping the LRU entry only if all are held).
2026-05-29 13:18:15 -07:00
teknium1andliuhao1024 8055d0f092 test(ntfy): cover echo-tag filter; tag standalone send path
Adds tests for the echo-loop fix (outgoing X-Tags header, inbound skip
on tagged events, genuine tags pass through) and extends the tag to the
out-of-process _standalone_send() path so cron / send_message deliveries
to a self-subscribed topic are also skipped. Maps both contributors in
release.py AUTHOR_MAP.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-05-29 13:17:46 -07:00
annguyenNous 9405cdc8dd fix(ntfy): prevent echo loop by tagging outgoing messages
When publish_topic equals the subscribe topic, the agent's own replies
are echoed back by ntfy as incoming messages, creating an infinite
reply spiral.

Fix: tag outgoing messages with X-Tags: hermes-agent header, and skip
incoming messages that carry this tag. This is zero-config — works
automatically regardless of topic configuration.

Fixes NousResearch/hermes-agent#34447
2026-05-29 13:17:46 -07:00
Bartok9 08c0b22417 fix(gateway): scope tool-result MEDIA scan to current turn
The post-run scan that appends tool-emitted MEDIA: tags to the final
response iterated every tool/function message in the full conversation
and relied solely on path-based dedup against paths reconstructed from
the replayable transcript. When that reconstruction does not byte-match
the in-memory tool content (timestamp stripping, observed-context
withholding, compression rewrites), a stale path emitted several turns
earlier is absent from the dedup set and leaks onto a later text-only
reply (Telegram 'Sending media group of 1 photo(s)' with no MEDIA
directive present).

Scope the scan to this turn's new messages by slicing result['messages']
at len(agent_history) (agent_history is passed as conversation_history
into run_conversation, so the returned list is history + this turn).
Retain path-based dedup as a secondary guard and as the sole guard on
the compression-shrink fallback, preserving the #160 behaviour.

Closes #34608
2026-05-29 13:13:34 -07:00
teknium1 38c4f8c371 test(gateway): update system-unit cwd assertion to HERMES_HOME anchor
test_system_unit_has_no_root_paths asserted the system unit's
WorkingDirectory was the remapped *checkout* path
(/home/alice/.hermes/hermes-agent). That is the brittle pin this PR
fixes — the system unit now anchors cwd at the target user's HERMES_HOME
(/home/alice/.hermes). The test's intent (no root-home leak, target-user
paths present) is unchanged and still holds.
2026-05-29 12:36:59 -07:00
teknium1 a1cb5fa2c7 fix(gateway): anchor service WorkingDirectory at HERMES_HOME, not the source checkout
The systemd unit (and launchd plist) pinned WorkingDirectory to PROJECT_ROOT
(the checkout the unit was generated from). When that checkout is transient —
a git worktree, or a clone hermes update later relocates/removes — the path
rots. systemd then fails the start at the CHDIR step (status=200/CHDIR) BEFORE
Python loads, so the on-boot refresh_systemd_unit_if_needed() self-heal never
runs and Restart=always crash-loops forever on a dead directory. Observed in
the wild: a gateway that crash-looped 153 times overnight, bot offline until a
manual 'hermes gateway restart' regenerated the unit.

Anchor cwd at HERMES_HOME instead — it never moves, always exists, and the
gateway never needed cwd to be the checkout (ExecStart uses an absolute python
+ -m hermes_cli.main). Existing broken units now differ from the generated unit
and self-heal on the next start/restart/update.
2026-05-29 12:36:59 -07:00
Teknium 45b00bb49a fix(packaging): ship hermes_cli subpackages in wheel (#34811)
[tool.setuptools.packages.find] listed 'hermes_cli' without the
'hermes_cli.*' wildcard, so the wheel shipped hermes_cli/*.py but
dropped the dashboard_auth and proxy subpackages. The dashboard died
on every install with ModuleNotFoundError: No module named
'hermes_cli.dashboard_auth' (#34701); 'hermes proxy' was equally
broken.

Add the wildcard, and add a regression test that drives setuptools'
own find_packages against the live tree so any future subpackage
dropped from the include list fails CI instead of a user's container.
2026-05-29 12:36:09 -07:00
teknium1 8836b3a113 fix(cli): widen Windows .bat wrapper fix to custom-name alias path
The profile alias --name path in main.py rewrote the wrapper with a
hardcoded #!/bin/sh script right after create_wrapper_script(), clobbering
the .bat on Windows and reintroducing the exact bug for custom aliases.

create_wrapper_script() now takes an optional target so the alias file is
named after the alias while the -p content references the profile — one
platform-aware code path, no post-hoc rewrite.
2026-05-29 12:32:47 -07:00
liuhao1024 6312dd8c3a fix(cli): create .bat wrapper on Windows instead of POSIX shell script
On Windows, hermes profile create produced a #!/bin/sh script that the
shell cannot execute.  Now creates a .bat file with @echo off + %* on
Windows, and keeps the POSIX shell script on macOS/Linux.

Also fixes check_alias_collision to use 'where' instead of 'which' on
Windows, and remove_wrapper_script to find .bat files.

Fixes #34708
2026-05-29 12:32:47 -07:00
zapabob 30a0d5bc9e chore(release): map zapabob author email 2026-05-29 12:32:35 -07:00
zapabob aa283d1e4f fix(model): isolate custom provider picker credentials 2026-05-29 12:32:35 -07:00
Teknium 2fc2280e63 fix(cli): clarify panel clips choices off-screen on short terminals (#34808)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(cli): clarify panel clips choices off-screen on short terminals

The clarify multiple-choice panel is a height-less Window inside a
non-full-screen HSplit. When its content exceeds the viewport,
prompt_toolkit distributes height per child and clips the panel's tail
— where the choices live — so options render invisible/cut off (issue
#34645, reported on macOS Terminal.app).

Two budget-accounting bugs let the panel overflow:
- the compact-chrome decision ignored the question rows, so full chrome
  (3 blank separators) was kept even with no room
- the '… (question truncated)' marker was not counted against the
  question's row budget, overshooting by one row at a 1-row budget

Fix: reserve one question row in the compact decision, count the
truncation marker against the budget, and drop the question entirely
when the choices alone already exceed the viewport (choices are the
must-see content for a selection).
2026-05-29 12:32:31 -07:00
Teknium 27a2c4f36f fix(mcp): stop reporting false OAuth success when no token was obtained (#34807)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(mcp): stop reporting false OAuth success when no token was obtained

`hermes mcp login` reported "Authenticated — N tool(s) available" for
servers that serve tools/list without auth (e.g. Google's official Drive
MCP server) even when the OAuth flow never completed — dynamic client
registration 400'd because the provider doesn't support RFC 7591, so no
token was ever acquired. Every real tool call then hung until timeout
with no indication of why.

Login now verifies a token actually landed on disk after the probe. When
it didn't, it warns that authentication didn't complete and shows the
config needed to supply a pre-registered client_id/client_secret (the
existing, already-supported workaround for DCR-less providers).

Adds a docs pitfall for Google Drive / Atlassian-style providers.

Fixes #34775
2026-05-29 12:32:19 -07:00
Teknium 1cb850b674 fix(api_server): emit per-turn transcript on run.completed (#34703) (#34804)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(api_server): emit per-turn transcript on run.completed (#34703)

WebUI clients lost intermediate (pre-tool-call) assistant text after
switching session pages mid-stream. The session-chat SSE stream delivers
all assistant text as assistant.delta events under one message_id
interleaved with tool.* events, then a single assistant.completed
carrying only the final reply — so a client accumulating deltas into one
buffer cannot reconstruct intermediate text segments that preceded tool
calls, and they vanish from the live view (state.db persists them
correctly).

run.completed now carries the authoritative per-turn transcript
(assistant + tool messages for this turn, in client-safe shape) so any
SSE consumer can reconcile its live view against ground truth without a
separate GET /messages round-trip. Purely additive — clients that ignore
the field are unaffected.
2026-05-29 12:27:49 -07:00
Teknium b6ed3913d2 feat(skills): categorize tap skills from skills.sh.json grouping sidecar
A GitHub tap can ship a repo-root skills.sh.json (the published skills.sh
schema) declaring category groupings. The Skills Hub now reads it at index
time and uses each grouping title as the skill's category label, instead of
the tag-derived guess. Generic: any tap that ships the file gets real
categorization — NVIDIA's groupings (Inference AI, Decision Optimization,
GPU Development, etc.) flow through automatically.

- GitHubSource: _get_skillsh_groupings() fetches+caches the sidecar per repo;
  _parse_skillsh_groupings() flattens it to {skill_name: title};
  _list_skills_in_repo() stamps meta.extra['category']; _meta_to_dict now
  serializes extra so the category survives the index cache round-trip.
- extract-skills.py: prefers extra['category'] over the tag heuristic and
  exempts sidecar categories from the small-category to Other collapse.
- Docs + 12 tests.
2026-05-29 12:24:39 -07:00
Teknium 4de8009ce4 feat(skills): integrate NVIDIA/skills as a trusted skills hub tap
NVIDIA/skills is now a default trusted tap in the Hermes Skills Hub —
discoverable, browsable, searchable, and auto-updating through the same
pipeline that already serves OpenAI, Anthropic, and HuggingFace skills.

Rebased onto current main.
2026-05-29 12:24:39 -07:00
Teknium 1596bb287e fix(dashboard): chat tab works in gated (OAuth) mode (#34793)
The Chat/TUI dashboard tab showed a false "Session token unavailable"
error and never rendered the terminal whenever the dashboard ran in
gated mode (OAuth auth gate active, --insecure not set), even though
the user was fully authenticated and every other tab worked.

Two checks in ChatPage.tsx gated purely on window.__HERMES_SESSION_TOKEN__,
which the server intentionally omits in gated mode (web_server.py only
injects __HERMES_AUTH_REQUIRED__=true there; the SPA is expected to use
cookie auth + a single-use WS ticket). buildWsAuthParam() already resolves
WS auth correctly for both modes, but the early bail prevented the effect
from ever reaching it.

Both checks now also honor __HERMES_AUTH_REQUIRED__: the banner no longer
fires and the xterm/WS effect no longer bails in gated mode.

Reported-by: wbrione <wbrione@users.noreply.github.com>
Closes #34755
2026-05-29 12:19:51 -07:00
Teknium 90b3c54de9 fix: drain thread no longer crashes on fd-less stdout streams (#34789)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix: drain thread no longer crashes on fd-less stdout streams

The _wait_for_process drain thread called proc.stdout.fileno()
unconditionally. ProcessHandle implementations whose stdout is not
backed by a real OS fd (iterator-style in-memory streams, mock procs)
raised 'list_iterator' object has no attribute 'fileno' (or
'fileno() returned a non-integer' from select.select), killing the
daemon thread and silently losing all process output.

Resolve the fd defensively at the top of _drain; when stdout has no
usable integer fileno, fall back to draining it as an iterable (the
legacy 'for line in proc.stdout' contract). The real subprocess /
os.pipe-backed select() fast path is unchanged.
2026-05-29 12:16:57 -07:00
teknium1 5641ae6469 chore(release): add AUTHOR_MAP entries for Bucket-1 docs salvage contributors 2026-05-29 12:06:22 -07:00
Twanislas 549a69a925 docs(curator): align 'agent-created' definition with actual provenance semantics
The curator docs stated that any skill not bundled/hub-installed was
'agent-created' and subject to curation — including foreground-created
skills and hand-written ones. Since PR #19621 (May 2026), the curator
requires an explicit  marker in .usage.json, which
only the background self-improvement review fork sets.

Changes:
- Rewrite 'What agent-created means' to document the 3-step eligibility
  check (not bundled + not hub + created_by=agent marker)
- Explain that foreground skill_manage(create) does NOT mark skills as
  agent-created (user-directed by design)
- Warn that hand-written skills are NOT curated
- Add note in Per-run reports explaining the '(not resolved)' display
  when no candidates exist (LLM pass skipped, not a config error)
- Link to skill_provenance.py for the write-origin ContextVar

Ref: PR #19621, tools/skill_provenance.py, tools/skill_manager_tool.py
2026-05-29 12:06:22 -07:00
Aman113114-IITD 3f0d44af8a docs: replace invalid 'hermes config get <key>' with 'hermes config show'
'hermes config get <key>' is referenced in three guides but is not a
valid subcommand. The valid subcommands under 'hermes config' are
{show,edit,set,path,env-path,check,migrate}. 'hermes config show' is
already used elsewhere in the docs (including 'hermes config show |
grep <pattern>' in the FAQ), so it's the idiomatic replacement.

- work-with-skills.md: 'View all skill config' now uses
  'hermes config show | grep ^skills\.config'
- migrate-from-openclaw.md: session-policy check now reads the value
  from 'hermes config show'
- configuring-models.md: 'inspect what the CLI will actually use'
  now uses 'hermes config show | grep ^model\.'

Refs #30195
2026-05-29 12:06:22 -07:00
HKPA eff4626747 fix(docs): add baseUrl prefix to SVG image paths in sessions and CLI pages
Fixes #24809

The docs site uses baseUrl='/docs/' but the <img> tags in sessions.md
and cli.md referenced images at /img/docs/... which resolves to a 404.
The static files are served at /docs/img/docs/... instead.

Before: <img src="/img/docs/session-recap.svg"> → 404
After:  <img src="/docs/img/docs/session-recap.svg"> → 200

Also fixes cli-layout.svg which had the same issue.
2026-05-29 12:06:22 -07:00
aqilazizandCodex 175885218e fix(docs): align fallback provider config examples
Use the current top-level fallback_providers list in fallback docs and keep fallback_model documented only as the legacy compatibility shape. Also align cron and delegation fallback coverage with current runtime behavior.

Closes #19691

Co-authored-by: Codex <codex@openai.com>
2026-05-29 12:06:22 -07:00
helix4u 119390a2a1 docs(config): deprecate MESSAGING_CWD guidance 2026-05-29 12:06:22 -07:00
helix4u 3625dbb844 docs(security): update redaction skill source 2026-05-29 12:06:22 -07:00
helix4u aef04b2b53 docs(security): fix secret redaction default docs 2026-05-29 12:06:22 -07:00
TonyPepe a2d3cff53f docs(cli): refine update gateway restart wording 2026-05-29 12:06:22 -07:00
TonyPepe ee0a9bf7c7 docs(cli): align hermes update flags 2026-05-29 12:06:22 -07:00
WadydX b922e3ff93 docs(prompt): align precedence docs with system prompt runtime
- Replace outdated linear ordering in prompt-assembly guide with
  current stable/context/volatile tier contract from system_prompt.py
- Clarify where memory/profile snapshots live versus skills guidance
- Document that pre_llm_call context is user-message injection, not
  cached system-prompt mutation
- Update architecture guide wording to reference system_prompt.py +
  prompt_builder.py tiered assembly

Closes #34118
2026-05-29 12:06:22 -07:00
Octavio Turra 053969fd53 Correct URL format for simplex-chat download
Fix download link for Linux/macOS binary in documentation.
2026-05-29 12:06:22 -07:00
alelpoan 988cf1743b fix(docs): replace channel link with actual playlist URL in quickstart 2026-05-29 12:06:22 -07:00
kurobaryo 03bdeaa876 docs: fix BROWSERBASE_SESSION_TIMEOUT unit (ms → seconds) 2026-05-29 12:06:22 -07:00
haran2001 d86710528a docs(google-workspace): fix dead gws CLI link to googleworkspace/cli
The Google Workspace skill doc linked to https://github.com/nicholasgasior/gws
which returns 404. The actual upstream CLI lives at
https://github.com/googleworkspace/cli (the official Google Workspace CLI in
Rust, dynamically built from the Google Discovery Service).

Closes #28922
2026-05-29 12:06:22 -07:00
Niels Kaspers 6891e05e78 docs: fix session recap image baseUrl 2026-05-29 12:06:22 -07:00
hllqkb 0673638560 fix(docs): correct GitHub org links in memory-providers.md
hermes-ai/hermes-agent → NousResearch/hermes-agent (2 occurrences).
The old org name leads to 404 pages.
2026-05-29 12:06:22 -07:00
Hashclaw ae9dfa510e docs: fix separate typo; hyphenate built-in trust wording
- ACL LaTeX template comment: seperate -> separate
- CONTRIBUTING and docs site: builtin trust -> built-in trust (prose/table cells)

Made-with: Cursor
2026-05-29 12:06:22 -07:00
kshitijandBartok9 7379f17556 fix(gateway): only fire planned-stop watcher for self-targeting markers + fix Windows consume (#34749)
* fix(gateway): only fire planned-stop watcher for markers targeting self

Salvaged from #34599 — rebased onto current main.

The planned-stop watcher now only fires shutdown for a marker that targets
the current process, instead of any marker that exists on disk. Fixes the
Windows crash loop (#34597) where a stale marker from a previous Gateway
instance kills a freshly booted Gateway ~400ms after start with a false
"Received UNKNOWN — initiating shutdown".

Co-authored-by: Bartok9 <danielrpike9@gmail.com>

* fix(gateway): match planned-stop/takeover markers by PID alone when start_time is unavailable

Follow-up to the #34599 salvage. The watcher's non-destructive probe
(planned_stop_marker_targets_self) already falls back to PID equality when
a process start_time is unavailable, but the authoritative consume it gates
(_consume_pid_marker_for_self) still required a non-None start_time match.

_get_process_start_time reads /proc/<pid>/stat and returns None on macOS and
native Windows — the only platform the planned-stop watcher exists for. So on
Windows the probe would fire the shutdown handler (PID matches) but the
handler's consume_planned_stop_marker_for_self() would return False, and a
legitimate 'hermes gateway stop' was still misclassified as an unexpected
UNKNOWN exit (exit 1) and revived by the service manager — a residual half of
the #34597 crash loop on the legitimate-stop path.

Align the consume with the probe: when both start_times are known they must
match (PID-reuse guard preserved on Linux); when either is unavailable, fall
back to PID equality alone, bounded by the existing short marker TTL. This
also fixes the parallel --replace takeover consume on Windows, which shares
the same helper.

Adds regression tests for the Windows (None start_time) path, the foreign-PID
rejection under that fallback, and confirmation the start_time-mismatch guard
still rejects when both are known.

---------

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
2026-05-29 17:36:58 +00:00
alt-glitch 0563ab0652 fix(test): add fal_client.submit stub to surface matrix test
The plugin switched from fal_client.subscribe() to submit()+handle.get().
The test mock only had subscribe, causing CI failures.
2026-05-29 22:26:24 +05:30
alt-glitch e46e4bcf47 fix(video_gen): parse duration suffix in success_response
int(payload["duration"]) blows up on "4s" (veo3.1 format).
Strip non-digit chars before int conversion in the response builder.
2026-05-29 22:26:24 +05:30
alt-glitch 3183b2e28c fix(video_gen): veo3.1 duration format and 4k resolution
FAL veo3.1 API expects duration as "4s"/"6s"/"8s" (with unit suffix),
not bare "4"/"6"/"8" like other families. Add per-family duration_suffix
field and apply it in _build_payload. Also add "4k" to veo3.1 resolutions
per FAL API docs.

Note: the managed gateway currently rejects the "4s" format (expects
integer duration). Gateway-side fix needed for veo3.1 to work through
the Nous subscription path.
2026-05-29 22:26:24 +05:30
alt-glitch a4c18f65d4 feat(video_gen): wire Nous subscription override into hermes tools UX
Add the same managed-gateway UX that image_gen already has:

- TOOL_CATEGORIES['video_gen'] gets a 'Nous Subscription' provider row
  with managed_nous_feature='video_gen' + video_gen_plugin_name='fal'
- NousSubscriptionFeatures gains a video_gen property + feature state
  computation (managed/active/available using the fal-queue gateway)
- _GATEWAY_TOOL_LABELS, _GATEWAY_DIRECT_LABELS, _ALL_GATEWAY_KEYS,
  _get_gateway_direct_credentials, opted_in all include video_gen
- apply_nous_managed_defaults and apply_gateway_defaults handle video_gen
- _is_toolset_satisfied checks Nous features for video_gen
- _is_provider_active detects managed video_gen (use_gateway + fal provider)
- _select_plugin_video_gen_provider accepts use_gateway kwarg, propagated
  from all 4 call sites in _configure_provider when managed_feature is set
- hermes setup status shows 'Video Generation (FAL via Nous subscription)'

Users on a Nous subscription can now pick 'Nous Subscription' under
hermes tools → Video Generation, which sets video_gen.provider=fal +
video_gen.use_gateway=true. The FAL plugin's _resolve_managed_fal_video_gateway
then routes through the managed queue gateway — no FAL_KEY needed.
2026-05-29 22:26:24 +05:30
alt-glitch b6294ea9f1 test(video_gen): cover gateway decision matrix gaps and 4xx error path
- Add test for 4xx ValueError with actionable remediation message
- Add test for is_available() returning True via managed gateway
- Add test for prefers_gateway overriding direct FAL_KEY
- Add test for is_available() via gateway in plugin test file
2026-05-29 22:26:24 +05:30
alt-glitch d04b3c193e feat(video_gen): route FAL video gen through managed Nous gateway
Wire plugins/video_gen/fal/__init__.py to use the same
_ManagedFalSyncClient pattern that image gen already uses.

Changes:
- Add managed gateway resolution, client caching, and
  _submit_fal_video_request() that routes between direct FAL_KEY
  and Nous gateway modes
- Update is_available() to return True when either FAL_KEY or the
  managed gateway is reachable
- Update generate() to use submit+get handle pattern instead of
  fal_client.subscribe() directly
- Fix happy-horse endpoint namespace: fal-ai/ → alibaba/ (matches
  the tool-gateway allowlist from fal-video-gen branch)
- Surface actionable error on 4xx gateway rejections

Tests:
- 4 new tests in test_managed_media_gateways.py (gateway routing,
  client reuse, direct mode fallback, alibaba namespace)
- Updated existing test_fal_plugin.py fixture to use submit/handle
  pattern and patch _resolve_managed_fal_video_gateway for isolation
2026-05-29 22:26:24 +05:30
kshitijk4poor 5cd0673217 ci: harden supply-chain gate jobs against changes-job failure
The scan-gate / dep-bounds-gate jobs use needs.changes; if the changes
job itself fails, its dependents would be skipped via a failed dependency
(not a conditional skip), leaving the required check unreported — the same
"pending forever" failure this PR fixes. Add always() and switch the gate
condition from == 'false' to != 'true' so the gate still fires (and reports
SUCCESS) when changes fails and its output is empty.
2026-05-29 09:17:01 -07:00
ethernet 6bc309baf2 ci: ensure required checks always report status
Remove paths filters from contributor-check and supply-chain-audit
workflows. When no matching files changed, the workflows never ran and
the required checks (check-attribution, supply chain scan, dep bounds)
stayed "pending" forever, blocking merge.

Now both workflows always trigger. A path-check step/job determines
whether the real work should run; gate jobs with matching names report
success when the real job was skipped, so branch protection always
gets a check status.

Also fixes dep-bounds: the old condition
  if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
was always true (the || true made it unconditional). Now uses the
proper changes.deps output from the shared filter job.
2026-05-29 09:17:01 -07:00
ethernet 6928692cec Merge pull request #33773 from dvir-pashut/fix/nix-full-drop-stale-vercel-group
fix(nix): drop stale "vercel" group from #full variant
2026-05-29 11:16:25 -04:00
teknium1 75cd420b3b docs(skills): move antigravity-cli to autonomous-ai-agents in catalog + sidebar 2026-05-29 05:21:48 -07:00
teknium1 78d7fa1b5c refactor(skills/antigravity-cli): move to autonomous-ai-agents (it's an AI agent CLI) 2026-05-29 05:21:48 -07:00
teknium1 904c0b479b refactor(state): return FTS index count from vacuum()
Have vacuum() return optimize_fts()'s count so the CLI 'sessions optimize'
summary uses the real merged-index count instead of probing the private
_FTS_TABLES / _fts_table_exists() members.
2026-05-29 05:09:56 -07:00
kshitijk4poor 38695254f8 perf(state): merge FTS5 segments on VACUUM + add 'hermes sessions optimize'
The FTS5 indexes (messages_fts, messages_fts_trigram) grow as a series of
incremental b-tree segments — one per trigger-driven insert batch. SQLite's
automerge caps at ~16 segments, so a long-lived store keeps scanning many
segments per MATCH and never collapses them unless the special 'optimize'
command runs. Nothing in the codebase ever ran it: vacuum() only fired after
a prune that deleted rows, and even then never merged FTS segments.

Changes:
- SessionDB.optimize_fts(): merges each FTS5 index to a single segment,
  probing for the (optional/lazy) trigram table first so it is safe to call
  unconditionally. Layout-only — search results and snippet() are unchanged.
- vacuum() now calls optimize_fts() before VACUUM so freed index pages are
  returned to the OS in the same pass.
- 'hermes sessions optimize' CLI subcommand for on-demand reclamation +
  segment compaction (previously there was no way to compact the store
  without a prune deleting rows), with before/after size reporting.

Benchmark (8000 msgs, fragmented to 8 segments/index):
- segments 8 -> 1 on both indexes
- porter MATCH 5.5x faster (0.449 -> 0.081 ms/q)
- trigram MATCH 3.0x faster (0.632 -> 0.207 ms/q)
- 8000 matches before == 8000 after, identical row ids (no functional change)

Orthogonal to the structural FTS-size PRs (#20239 external-content,
#27770 optional trigram) — segment merge helps regardless of those.

Tests: TestOptimizeFts covers index count, search+snippet preservation,
missing-trigram path, and idempotency. Full test_hermes_state.py green (227).
2026-05-29 05:09:56 -07:00
Teknium 2159d2a729 docs(credential-pools): document immediate rotation on usage-limit 429 (#34580)
The rotation flowchart only described the generic 'retry once, rotate on
second 429' path. ChatGPT/Codex plan-limit 429s carry a usage_limit_reached
reason and rotate to the next pool key immediately (no retry, since the cap
won't clear on retry). Document that case so the docs match the code.
2026-05-29 04:50:14 -07:00
teknium1 0dba60f73b docs(skills): regen catalog + sidebar for optional antigravity-cli skill 2026-05-29 04:49:42 -07:00
teknium1 632a7088a3 chore(skills/antigravity-cli): make optional, frame through Hermes tools, tighten frontmatter 2026-05-29 04:49:42 -07:00
Tony Simons 1bba5f27ab feat(skills): add antigravity-cli operator skill 2026-05-29 04:49:42 -07:00
teknium1 d6f2bdabda docs(skills): regen catalog + sidebar for optional grok skill 2026-05-29 04:49:38 -07:00
teknium1 99ddba94ed chore(skills/grok): make optional + tighten SKILL.md to modern format 2026-05-29 04:49:38 -07:00
Matt Maximo 10cd4138cc feat(skills): add grok skill for xAI Grok Build CLI
Adds a `grok` skill under `skills/autonomous-ai-agents/`, a third coding-agent orchestration guide alongside `codex` and `claude-code`. It teaches Hermes to delegate coding tasks to Grok Build (xAI's `grok` CLI).

- Headless `-p` one-shots (preferred)
- Interactive TUI via pty + tmux
- Session resume, background tasks, structured JSON output
- PR review and parallel worktree patterns
- Auth via SuperGrok / X Premium+ (`grok login`)
- Full pitfalls and config notes
2026-05-29 04:49:38 -07:00
Teknium 5e7c2ffa9f chore(models): gemini-3.5-flash replaces gemini-3-flash-preview in OpenRouter + Nous lists (#34581)
* chore(models): swap gemini-3-flash-preview for gemini-3.5-flash in OpenRouter + Nous lists

* chore(models): regenerate model-catalog.json for gemini-3.5-flash swap
2026-05-29 04:27:58 -07:00
dvir pashut 66265a0571 fix(nix): drop stale "vercel" group from #full variant
The `vercel` optional-dependency was removed from pyproject.toml in
#33067, but `nix/packages.nix` (added a few hours later in #33108)
still references `"vercel"` in the `#full` variant's
`extraDependencyGroups`. uv2nix fails evaluation with:

  error: Extra/group name 'vercel' does not match either extra or
  dependency group

Because `nix/devShell.nix` does
`inputsFrom = builtins.attrValues self'.packages`, the broken `#full`
derivation is pulled into the dev shell too, so `nix develop` /
direnv breaks on a fresh clone — not just `nix build .#full`.
2026-05-28 11:52:31 +03:00
315 changed files with 20712 additions and 2561 deletions
+17 -5
View File
@@ -3,11 +3,9 @@ name: Contributor Attribution Check
on:
pull_request:
branches: [main]
paths:
# Only run when code files change (not docs-only PRs)
- '*.py'
- '**/*.py'
- '.github/workflows/contributor-check.yml'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
permissions:
contents: read
@@ -20,7 +18,21 @@ jobs:
with:
fetch-depth: 0 # Full history needed for git log
- name: Check if relevant files changed
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
if [ -n "$CHANGED" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No Python files changed, skipping attribution check."
fi
- name: Check for unmapped contributor emails
if: steps.filter.outputs.run == 'true'
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
+67 -10
View File
@@ -3,15 +3,9 @@ name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**/*.py'
- '**/*.pth'
- '**/setup.py'
- '**/setup.cfg'
- '**/sitecustomize.py'
- '**/usercustomize.py'
- '**/__init__.pth'
- 'pyproject.toml'
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
permissions:
pull-requests: write
@@ -27,8 +21,44 @@ permissions:
# advisory-only workflow instead.
jobs:
# ── Path filter (shared by both scan and dep-bounds) ───────────────
changes:
runs-on: ubuntu-latest
outputs:
# True when any file the scanner cares about changed in this PR
scan: ${{ steps.filter.outputs.scan }}
# True when pyproject.toml changed in this PR
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'*.py' '**/*.py' '*.pth' '**/*.pth' \
'setup.py' 'setup.cfg' \
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
'pyproject.toml' || true)
if [ -n "$SCAN_FILES" ]; then
echo "scan=true" >> "$GITHUB_OUTPUT"
else
echo "scan=false" >> "$GITHUB_OUTPUT"
fi
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
if [ -n "$DEPS_FILES" ]; then
echo "deps=true" >> "$GITHUB_OUTPUT"
else
echo "deps=false" >> "$GITHUB_OUTPUT"
fi
scan:
name: Scan PR for critical supply chain risks
needs: changes
if: needs.changes.outputs.scan == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -147,10 +177,24 @@ jobs:
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
# Gate: reports success when scan was skipped (no relevant files changed).
# This ensures the required check always gets a status.
scan-gate:
name: Scan PR for critical supply chain risks
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.scan != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No supply-chain-relevant files changed, skipping scan."
dep-bounds:
name: Check PyPI dependency upper bounds
needs: changes
if: needs.changes.outputs.deps == 'true'
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -211,3 +255,16 @@ jobs:
run: |
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
# This ensures the required check always gets a status.
dep-bounds-gate:
name: Check PyPI dependency upper bounds
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.deps != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
+1 -1
View File
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
- Document handling, web research, common dev workflows, system administration
- Used regularly by a wide range of people
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
+37 -1
View File
@@ -1783,11 +1783,25 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
tool_result_ids.add(block.get("tool_use_id"))
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
m["content"] = [
kept = [
b
for b in m["content"]
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
]
# If stripping an orphaned tool_use mutated a turn that also carries a
# signed thinking block, that block's Anthropic signature was computed
# against the ORIGINAL (un-stripped) turn content and is now invalid.
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
# the latest assistant message cannot be modified". Flag the turn so
# _manage_thinking_signatures can demote the dead signature instead of
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
if len(kept) != len(m["content"]) and any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in m["content"]
):
m["_thinking_signature_invalidated"] = True
m["content"] = kept
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
@@ -1832,6 +1846,10 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
fixed[-1]["content"] = prev_content + curr_content
else:
# Consecutive assistant messages — merge text content.
# Propagate the orphan-strip signature-invalidation flag onto the
# surviving (prev) dict so _manage_thinking_signatures still sees it.
if m.get("_thinking_signature_invalidated"):
fixed[-1]["_thinking_signature_invalidated"] = True
# Drop thinking blocks from the *second* message: their
# signature was computed against a different turn boundary
# and becomes invalid once merged.
@@ -1920,11 +1938,26 @@ def _manage_thinking_signatures(
else:
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
# to text so the reasoning isn't lost.
#
# Exception: if orphan-stripping (or another structural mutation) removed
# a tool_use block from THIS turn, every thinking signature on it was
# computed against the original turn content and is now dead. Anthropic
# rejects the turn either way — replaying the signed block 400s with
# "thinking blocks in the latest assistant message cannot be modified",
# and a bare signed block with no following tool_use is also invalid.
# Demote ALL thinking blocks on this turn to text so the turn replays
# cleanly and the model can re-plan from the surviving tool results.
signature_dead = bool(m.get("_thinking_signature_invalidated"))
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if signature_dead:
thinking_text = b.get("thinking", "")
if thinking_text:
new_content.append({"type": "text", "text": thinking_text})
continue
if b.get("type") == "redacted_thinking":
# Redacted blocks use 'data' for the signature payload —
# drop the block when 'data' is missing (can't be validated).
@@ -1944,6 +1977,9 @@ def _manage_thinking_signatures(
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
b.pop("cache_control", None)
# Drop the internal bookkeeping flag — it must never reach the API payload.
m.pop("_thinking_signature_invalidated", None)
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
+52 -19
View File
@@ -1680,26 +1680,48 @@ def _read_main_provider() -> str:
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
_RUNTIME_MAIN_MODEL: str = ""
_RUNTIME_MAIN_BASE_URL: str = ""
_RUNTIME_MAIN_API_KEY: str = ""
_RUNTIME_MAIN_API_MODE: str = ""
def set_runtime_main(provider: str, model: str) -> None:
"""Record the live runtime provider/model for the current AIAgent.
def set_runtime_main(
provider: str,
model: str,
*,
base_url: str = "",
api_key: str = "",
api_mode: str = "",
) -> None:
"""Record the live runtime provider/model/credentials for the current AIAgent.
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
equivalent setter) at the top of each turn so that
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
overrides instead of the stale config.yaml default.
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
recorded so that ``_resolve_auto`` can construct a valid client in
Step 1 instead of falling through to the aggregator chain.
"""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
_RUNTIME_MAIN_MODEL = (model or "").strip()
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
def clear_runtime_main() -> None:
"""Clear the runtime override (e.g. on session end)."""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
_RUNTIME_MAIN_PROVIDER = ""
_RUNTIME_MAIN_MODEL = ""
_RUNTIME_MAIN_BASE_URL = ""
_RUNTIME_MAIN_API_KEY = ""
_RUNTIME_MAIN_API_MODE = ""
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
@@ -2980,6 +3002,18 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
runtime_api_key = runtime.get("api_key", "")
runtime_api_mode = str(runtime.get("api_mode") or "")
# Fall back to process-local globals when main_runtime dict was not
# provided or was incomplete. ``set_runtime_main()`` now records
# base_url/api_key/api_mode alongside provider/model, so custom:
# providers get the full credential surface in Step 1 of the
# auto-detect chain.
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
runtime_base_url = _RUNTIME_MAIN_BASE_URL
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
runtime_api_key = _RUNTIME_MAIN_API_KEY
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
runtime_api_mode = _RUNTIME_MAIN_API_MODE
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
# scenario where a user switches providers via `hermes model` but the
@@ -4725,24 +4759,23 @@ def _build_call_kwargs(
kwargs["temperature"] = temperature
if max_tokens is not None:
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
_model_lower = (model or "").lower()
_skip_max_tokens = (
provider == "zai"
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
# We do NOT cap output by default. Most chat-completions providers treat
# an omitted max_tokens as "use the model's max output", which is what we
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
# an explicit cap only risks truncating a summary or 400-ing on providers
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
# models reject it entirely with error 1210). Omitting it sidesteps all of
# those wire-format quirks at once.
#
# The one exception is the Anthropic Messages wire (MiniMax and any
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
# there.
_effective_base = base_url or (
_current_custom_base_url() if provider == "custom" else ""
)
if _skip_max_tokens:
pass # ZAI vision models do not accept max_tokens
elif provider == "custom":
custom_base = base_url or _current_custom_base_url()
if base_url_hostname(custom_base) == "api.openai.com":
kwargs["max_completion_tokens"] = max_tokens
else:
kwargs["max_tokens"] = max_tokens
else:
if _is_anthropic_compat_endpoint(provider, _effective_base):
kwargs["max_tokens"] = max_tokens
if tools:
+12
View File
@@ -1283,6 +1283,18 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
agent._copy_reasoning_content_for_api(msg, api_msg)
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
# Completions schema. The main loop drops these via
# ChatCompletionsTransport.convert_messages(), but the summary path
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
agent._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)
+114 -13
View File
@@ -40,17 +40,47 @@ SUMMARY_PREFIX = (
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"If the latest user message is consistent with the '## Active Task' "
"section, you may use the summary as background. If the latest user "
"message contradicts, supersedes, changes topic from, or in any way "
"diverges from '## Active Task' / '## In Progress' / '## Pending User "
"Asks' / '## Remaining Work', the latest message WINS — discard those "
"stale items entirely and do not 'wrap up the old task first'. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:",
)
# Minimum tokens for the summary output
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
@@ -518,6 +548,10 @@ class ContextCompressor(ContextEngine):
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def update_model(
self,
@@ -615,6 +649,10 @@ class ContextCompressor(ContextEngine):
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
self.summary_model = summary_model_override or ""
@@ -648,6 +686,44 @@ class ContextCompressor(ContextEngine):
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
if self.last_prompt_tokens > 0:
self.last_real_prompt_tokens = self.last_prompt_tokens
if self.last_prompt_tokens < self.threshold_tokens:
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
else:
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""Return True when a high rough preflight estimate is known-noisy.
``estimate_request_tokens_rough(..., tools=...)`` intentionally
overestimates schema-heavy requests so Hermes compresses before a
provider rejects the payload. After a successful compressed API call,
though, provider ``prompt_tokens`` are a better signal than repeating
compaction from the same rough schema overhead. Defer only while the
rough estimate has grown modestly since a request the provider proved
fit under the threshold.
"""
if rough_tokens < self.threshold_tokens:
return False
if self.last_real_prompt_tokens <= 0:
return False
if self.last_real_prompt_tokens >= self.threshold_tokens:
return False
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
if baseline <= 0:
return False
growth = max(0, rough_tokens - baseline)
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
if growth > tolerated_growth:
return False
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
return True
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Check if context exceeds the compression threshold.
@@ -1190,11 +1266,27 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
task assignment verbatim the exact words they used. If multiple tasks
were requested and only some are done, list only the ones NOT yet completed.
Continuation should pick up exactly here. Example:
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
- Decisions awaiting input ("optie A of B?")
- Ongoing discussions where the assistant owes the next substantive reply
A conversation where the user just asked a question IS an active task the
task is "answer that question with full context". Do NOT write "None" merely
because the user did not issue an imperative command; reserve "None" for the
rare case where the last exchange was fully resolved and the user said
something like "thanks, that's all".
If multiple items are outstanding, list only the ones NOT yet completed.
Continuation should pick up exactly here. Examples:
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
"User chose option A; awaiting implementation of step 2"
If the user's most recent message was a reverse signal (stop, undo, roll
back, never mind, just verify, change of topic) that supersedes earlier
work, write the reverse signal verbatim and DO NOT carry forward the
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
verify the current diff' — earlier i18n in-flight work is cancelled."
If no outstanding task exists, write "None."]
## Goal
@@ -1260,7 +1352,7 @@ PREVIOUS SUMMARY:
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved.
{_template_sections}"""
else:
@@ -1424,9 +1516,16 @@ The user has requested that this compaction PRIORITISE preserving all informatio
@staticmethod
def _strip_summary_prefix(summary: str) -> str:
"""Return summary body without the current or legacy handoff prefix."""
"""Return summary body without the current, legacy, or any historical
handoff prefix.
Historical prefixes must be stripped too: a handoff persisted under an
older prefix can be inherited into a resumed lineage (#35344), and if we
only re-prepend the current prefix without removing the old one, the
stale directive it carried stays embedded in the body.
"""
text = (summary or "").strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
return text[len(prefix):].lstrip()
return text
@@ -1440,7 +1539,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
text = _content_text_for_contains(content).lstrip()
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def _find_latest_context_summary(
+9
View File
@@ -115,6 +115,15 @@ class ContextEngine(ABC):
"""
return False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""Return True when preflight should trust recent real usage instead.
Built-in compression uses this to avoid re-compacting from known-noisy
rough estimates after a compressed request has already fit. Third-party
engines can ignore it safely.
"""
return False
# -- Optional: manual /compress preflight ------------------------------
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
+30 -8
View File
@@ -575,19 +575,18 @@ def compress_context(
force=True,
)
# Update token estimate after compaction so pressure calculations
# use the post-compression count, not the stale pre-compression one.
# Use estimate_request_tokens_rough() so tool schemas are included —
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
# omitting them delays the next compression cycle far past the
# configured threshold (issue #14695).
# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.
_compressed_est = estimate_request_tokens_rough(
compressed,
system_prompt=new_system_prompt or "",
tools=agent.tools or None,
)
agent.context_compressor.last_prompt_tokens = _compressed_est
agent.context_compressor.last_compression_rough_tokens = _compressed_est
agent.context_compressor.last_prompt_tokens = -1
agent.context_compressor.last_completion_tokens = 0
agent.context_compressor.awaiting_real_usage_after_compression = True
# Clear the file-read dedup cache. After compression the original
# read content is summarised away — if the model re-reads the same
@@ -599,7 +598,7 @@ def compress_context(
pass
logger.info(
"context compression done: session=%s messages=%d->%d tokens=~%s",
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
agent.session_id or "none", _pre_msg_count, len(compressed),
f"{_compressed_est:,}",
)
@@ -645,6 +644,12 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
# be re-sent and rejected again, wasting the single retry budget. We only
# report success (caller retries) when every over-threshold image was
# actually brought under the target.
unshrinkable_oversized = 0
def _shrink_data_url(url: str) -> Optional[str]:
"""Return a smaller data URL, or None if shrink can't help."""
@@ -711,17 +716,34 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
if resized:
image_value["url"] = resized
changed_count += 1
elif isinstance(url, str) and url.startswith("data:") \
and len(url) > target_bytes:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
resized = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
changed_count += 1
elif image_value.startswith("data:") \
and len(image_value) > target_bytes:
unshrinkable_oversized += 1
if changed_count:
logger.info(
"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB",
changed_count, target_bytes / (1024 * 1024),
)
if unshrinkable_oversized:
# At least one oversized image could not be shrunk under the target.
# Retrying would re-send it and fail identically, so signal "no
# progress" even if other parts shrank — the caller will surface the
# original error rather than burning its single retry on a no-op.
logger.warning(
"image-shrink recovery: %d oversized image part(s) could not be "
"shrunk under %.0f MB — not retrying (would re-send rejected payload)",
unshrinkable_oversized, target_bytes / (1024 * 1024),
)
return False
return changed_count > 0
+110 -14
View File
@@ -392,6 +392,9 @@ def run_conversation(
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
@@ -600,18 +603,50 @@ def run_conversation(
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if agent.context_compressor.should_compress(_preflight_tokens):
if not _preflight_deferred:
# Keep the CLI/ACP context display in sync with what preflight
# actually measured. The status bar reads
# ``compressor.last_prompt_tokens``, which otherwise only updates
# from a *successful* API response. When the conversation has grown
# since the last successful call — or when compression then fails
# (e.g. the auxiliary summary model times out) and no fresh usage
# arrives — the bar stays stuck at the old, smaller value while
# preflight reports a much larger number, looking out of sync.
# Seed it with the fresh estimate (only ever revising upward; a real
# ``update_from_response`` will correct it after the next API call).
# Skipped when deferring — a deferred estimate is known to over-count
# vs the last real provider prompt, so trusting it for the display
# would re-introduce the very desync we're avoiding.
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{agent.context_compressor.threshold_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{agent.context_compressor.context_length:,}",
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# May need multiple passes for very large sessions with small
@@ -646,8 +681,8 @@ def run_conversation(
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if _preflight_tokens < agent.context_compressor.threshold_tokens:
break # Under threshold
if not _compressor.should_compress(_preflight_tokens):
break # Under threshold or anti-thrash guard stopped it
# Plugin hook: pre_llm_call
# Fired once per turn before the tool-calling loop. Plugins can
@@ -1457,7 +1492,8 @@ def run_conversation(
if retry_count >= max_retries:
# Try fallback before giving up
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -3059,12 +3095,17 @@ def run_conversation(
) and not is_context_length_error
if is_client_error:
# Try fallback before aborting — a different provider
# may not have the same issue (rate limit, auth, etc.)
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
# Try fallback before aborting — a different provider may
# not have the same issue (rate limit, auth, etc.). Only
# announce the attempt when a fallback chain actually
# exists; otherwise "trying fallback..." is a lie and the
# session looks like it's recovering when it's about to
# abort silently (#35314, #17446).
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -3207,7 +3248,8 @@ def run_conversation(
retry_count = 0
continue
# Try fallback before giving up entirely
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -3862,6 +3904,11 @@ def run_conversation(
# inflate completion_tokens with reasoning,
# causing premature compression. (#12026)
_real_tokens = _compressor.last_prompt_tokens
elif _compressor.last_prompt_tokens == -1:
# Compression just ran and no API-reported prompt count
# has arrived yet. Avoid treating a schema-heavy rough
# post-compression estimate as real context pressure.
_real_tokens = 0
else:
# Include tool schemas — with 50+ tools enabled
# these add 20-30K tokens the messages-only
@@ -4443,6 +4490,55 @@ def run_conversation(
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
# Turn-completion explainer.
# When a turn ends abnormally after substantive work — empty content
# after retries, a partial/truncated stream, a still-pending tool
# result, or an iteration/budget limit — the user otherwise gets a
# blank or fragmentary response box with no consolidated reason why
# the agent stopped (#34452). Surface a single user-visible
# explanation derived from ``_turn_exit_reason``, mirroring the
# file-mutation verifier footer pattern above.
#
# Gate carefully so healthy turns stay quiet:
# - ``text_response(...)`` exits never produce an explanation
# (handled inside the formatter), so a terse ``Done.`` is silent.
# - We only ACT when there is no genuinely usable reply this turn:
# an empty response, the "(empty)" terminal sentinel, or a
# suspiciously short partial fragment with no terminating
# punctuation (e.g. "The"). A real short answer keeps its text.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
# A short fragment that is not a normal text_response exit
# and lacks sentence-ending punctuation is treated as a
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
)
if _is_empty_terminal or _is_partial_fragment:
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment, append the reason so
# the user sees both what arrived and why it
# stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
# Plugin hook: transform_llm_output
+6 -13
View File
@@ -247,18 +247,13 @@ def _cmd_restart() -> int:
def _cmd_which(server_id: str) -> int:
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
import shutil as _shutil
from agent.lsp.install import INSTALL_RECIPES, _existing_binary
recipe = INSTALL_RECIPES.get(server_id)
bin_name = (recipe or {}).get("bin", server_id)
staged = hermes_lsp_bin_dir() / bin_name
if staged.exists():
sys.stdout.write(str(staged) + "\n")
return 0
on_path = _shutil.which(bin_name)
if on_path:
sys.stdout.write(on_path + "\n")
resolved = _existing_binary(bin_name)
if resolved:
sys.stdout.write(resolved + "\n")
return 0
sys.stderr.write(f"{server_id}: not installed\n")
return 1
@@ -292,11 +287,9 @@ def _backend_warnings() -> list:
suggestion across common platforms.
"""
import shutil as _shutil
from agent.lsp.install import hermes_lsp_bin_dir
from agent.lsp.install import _existing_binary
notes: list = []
bash_installed = _shutil.which("bash-language-server") is not None or (
(hermes_lsp_bin_dir() / "bash-language-server").exists()
)
bash_installed = _existing_binary("bash-language-server") is not None
if bash_installed and _shutil.which("shellcheck") is None:
notes.append(
"bash-language-server is installed but shellcheck is missing — "
+16 -3
View File
@@ -44,6 +44,7 @@ from __future__ import annotations
import asyncio
import logging
import os
import sys
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@@ -244,15 +245,27 @@ class LSPClient:
await self._cleanup_process()
raise
@staticmethod
def _win_wrap_cmd(cmd: List[str]) -> List[str]:
"""On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
exe = cmd[0]
if exe.lower().endswith((".cmd", ".bat")):
return ["cmd.exe", "/c", *cmd]
return cmd
async def _spawn(self) -> None:
env = dict(os.environ)
if self._env:
env.update(self._env)
cmd = self._command
if sys.platform == "win32":
cmd = self._win_wrap_cmd(cmd)
try:
self._proc = await asyncio.create_subprocess_exec(
self._command[0],
*self._command[1:],
cmd[0],
*cmd[1:],
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -261,7 +274,7 @@ class LSPClient:
)
except FileNotFoundError as e:
raise LSPProtocolError(
f"LSP server binary not found: {self._command[0]} ({e})"
f"LSP server binary not found: {cmd[0]} ({e})"
) from e
# Drain stderr at debug level — if we don't, the pipe buffer
+47 -23
View File
@@ -108,6 +108,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
_install_locks: Dict[str, threading.Lock] = {}
_install_results: Dict[str, Optional[str]] = {}
_install_lock_meta = threading.Lock()
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
def _is_windows() -> bool:
return os.name == "nt"
def hermes_lsp_bin_dir() -> Path:
@@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path:
return p
def _native_binary_candidates(base: Path) -> list[Path]:
"""Return platform-native executable candidates for a staged binary."""
candidates = [base]
if _is_windows():
existing = {str(base).lower()}
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
candidate = Path(str(base) + suffix)
key = str(candidate).lower()
if key not in existing:
candidates.append(candidate)
existing.add(key)
return candidates
def _existing_binary(name: str) -> Optional[str]:
"""Probe the staging dir + PATH for a binary named ``name``."""
staged = hermes_lsp_bin_dir() / name
if staged.exists() and os.access(staged, os.X_OK):
return str(staged)
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
if staged.exists() and os.access(staged, os.X_OK):
return str(staged)
on_path = shutil.which(name)
if on_path:
return on_path
if _is_windows():
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
on_path = shutil.which(f"{name}{suffix}")
if on_path:
return on_path
return None
@@ -250,12 +274,7 @@ def _install_npm(
# Find the bin
nm_bin = staging / "node_modules" / ".bin" / bin_name
if os.name == "nt":
# On Windows npm sometimes drops `.cmd` shims
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
else:
candidates = [nm_bin]
for c in candidates:
for c in _native_binary_candidates(nm_bin):
if c.exists():
# Symlink into our `lsp/bin/` for stable PATH access.
link = hermes_lsp_bin_dir() / c.name
@@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
logger.warning("[install] go install errored for %s: %s", pkg, e)
return None
bin_path = staging / bin_name
if os.name == "nt":
if _is_windows():
bin_path = bin_path.with_suffix(".exe")
if bin_path.exists():
return str(bin_path)
@@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("[install] pip install errored for %s: %s", pkg, e)
return None
# Look for the script
bin_path = pip_target / "bin" / bin_name
if bin_path.exists():
link = hermes_lsp_bin_dir() / bin_name
if not link.exists():
try:
link.symlink_to(bin_path)
except (OSError, NotImplementedError):
try:
shutil.copy2(bin_path, link)
except OSError:
return str(bin_path)
return str(link if link.exists() else bin_path)
# Look for the console script. POSIX wheels generally write to bin/,
# while native Windows installs use Scripts/.
script_dirs = [pip_target / "bin"]
if _is_windows():
script_dirs.append(pip_target / "Scripts")
for script_dir in script_dirs:
for bin_path in _native_binary_candidates(script_dir / bin_name):
if bin_path.exists():
link = hermes_lsp_bin_dir() / bin_path.name
if not link.exists():
try:
link.symlink_to(bin_path)
except (OSError, NotImplementedError):
try:
shutil.copy2(bin_path, link)
except OSError:
return str(bin_path)
return str(link if link.exists() else bin_path)
return None
+1 -9
View File
@@ -150,10 +150,6 @@ _JWT_RE = re.compile(
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
)
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
# E.164 phone numbers: +<country><number>, 7-15 digits
# Negative lookahead prevents matching hex strings or identifiers
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
@@ -331,7 +327,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
"""Apply all redaction patterns to a block of text.
Safe to call on any string -- non-matching text passes through unchanged.
Disabled by default enable via security.redact_secrets: true in config.yaml.
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
@@ -419,10 +415,6 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
if "&" in text and "=" in text:
text = _redact_form_body(text)
# Discord user/role mentions (<@snowflake_id>)
if "<@" in text:
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
# E.164 phone numbers (Signal, WhatsApp)
if "+" in text:
def _redact_phone(m):
+65 -51
View File
@@ -180,28 +180,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
except Exception:
pass
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
except Exception:
pass
# Checkpoint before destructive terminal commands
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
try:
cmd = function_args.get("command", "")
if _is_destructive_command(cmd):
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
agent._checkpoint_mgr.ensure_checkpoint(
cwd, f"before terminal: {cmd[:60]}"
)
except Exception:
pass
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
# We must know whether the tool will execute before touching
# checkpoint state (dedup slot, real snapshots).
block_result = None
blocked_by_guardrail = False
if _ts_scope_block is not None:
@@ -224,6 +205,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
block_result = agent._guardrail_block_result(guardrail_decision)
blocked_by_guardrail = True
# ── Checkpoint preflight (only for tools that will execute) ──
if block_result is None:
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
except Exception:
pass
# Checkpoint before destructive terminal commands
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
try:
cmd = function_args.get("command", "")
if _is_destructive_command(cmd):
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
agent._checkpoint_mgr.ensure_checkpoint(
cwd, f"before terminal: {cmd[:60]}"
)
except Exception:
pass
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
# ── Logging / callbacks ──────────────────────────────────────────
@@ -301,33 +306,38 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
start = time.time()
try:
result = agent._invoke_tool(
function_name,
function_args,
effective_task_id,
tool_call.id,
messages=messages,
pre_tool_block_checked=True,
)
except Exception as tool_error:
result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
duration = time.time() - start
is_error, _ = _detect_tool_failure(function_name, result)
if is_error:
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
else:
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
results[index] = (function_name, function_args, result, duration, is_error, False)
# Tear down worker-tid tracking. Clear any interrupt bit we may
# have set so the next task scheduled onto this recycled tid
# starts with a clean slate.
with agent._tool_worker_threads_lock:
agent._tool_worker_threads.discard(_worker_tid)
try:
_ra()._set_interrupt(False, _worker_tid)
except Exception:
pass
try:
result = agent._invoke_tool(
function_name,
function_args,
effective_task_id,
tool_call.id,
messages=messages,
pre_tool_block_checked=True,
)
except Exception as tool_error:
result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
duration = time.time() - start
is_error, _ = _detect_tool_failure(function_name, result)
if is_error:
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
else:
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
results[index] = (function_name, function_args, result, duration, is_error, False)
finally:
# Tear down worker-tid tracking. Clear any interrupt bit we may
# have set so the next task scheduled onto this recycled tid
# starts with a clean slate. This MUST be in a finally block
# because BaseException subclasses (CancelledError, KeyboardInterrupt)
# bypass ``except Exception`` and would otherwise leak the tid
# into _interrupted_threads, poisoning the recycled thread.
with agent._tool_worker_threads_lock:
agent._tool_worker_threads.discard(_worker_tid)
try:
_ra()._set_interrupt(False, _worker_tid)
except Exception:
pass
# Start spinner for CLI mode (skip when TUI handles tool progress)
spinner = None
@@ -753,10 +763,14 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks · (/agents to monitor)"
else:
goal_preview = (function_args.get("goal") or "")[:30]
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
spinner_label = (
f"🔀 {goal_preview} · (/agents to monitor)"
if goal_preview
else "🔀 delegating · (/agents to monitor)"
)
spinner = None
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
+1 -1
View File
@@ -163,7 +163,7 @@ model:
# -----------------------------------------------------------------------------
# Working directory behavior:
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
terminal:
backend: "local"
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
+306 -18
View File
@@ -787,8 +787,10 @@ def AIAgent(*args, **kwargs):
def get_tool_definitions(*args, **kwargs):
from hermes_cli.mcp_startup import wait_for_mcp_discovery
from model_tools import get_tool_definitions as _get_tool_definitions
wait_for_mcp_discovery()
return _get_tool_definitions(*args, **kwargs)
@@ -896,9 +898,12 @@ def _prepare_deferred_agent_startup() -> None:
exc_info=True,
)
try:
from tools.mcp_tool import discover_mcp_tools
from hermes_cli.mcp_startup import start_background_mcp_discovery
discover_mcp_tools()
start_background_mcp_discovery(
logger=logger,
thread_name="termux-cli-mcp-discovery",
)
except Exception:
logger.debug(
"MCP tool discovery failed at deferred CLI startup",
@@ -1537,9 +1542,17 @@ def _query_osc11_background() -> str | None:
Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
within a few ms. We wait up to 100ms total before giving up.
Returns "#RRGGBB" or None on timeout / non-tty.
Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
late reply lands after prompt_toolkit has grabbed the tty its payload
leaks in as typed text and the BEL terminator reads as Ctrl+G (open
editor), trapping the user in a stray editor. Remote sessions fall back to
COLORFGBG / env hints / the dark default instead.
"""
if not sys.stdin.isatty() or not sys.stdout.isatty():
return None
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
return None
try:
import termios
import tty
@@ -1587,8 +1600,11 @@ def _query_osc11_background() -> str | None:
r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
return f"#{r:02X}{g:02X}{b:02X}"
finally:
# TCSAFLUSH discards any unread input as it restores the original
# attributes — scrubs a slow/partial OSC 11 reply out of the tty
# buffer before prompt_toolkit can read it as keystrokes.
try:
termios.tcsetattr(fd, termios.TCSANOW, old)
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
except Exception:
pass
@@ -3248,6 +3264,12 @@ class HermesCLI:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._model_picker_state = None
# Armed when a bare `/resume` prints the recent-sessions list so the
# very next bare numeric input (e.g. `3`) resolves to that session.
# Holds the exact list used for index resolution; one-shot (cleared on
# the next submitted input, whether it's the selection or anything
# else). See #34584.
self._pending_resume_sessions = None
self._secret_state = None
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
@@ -3555,8 +3577,17 @@ class HermesCLI:
compressor = getattr(agent, "context_compressor", None)
if compressor:
# last_prompt_tokens is parked at the -1 sentinel right after a
# compression, until the next real API call reports a prompt count
# (awaiting_real_usage_after_compression). The status bar must not
# render that sentinel verbatim — it produced "-1/200K" / "-1%".
# Clamp it to 0 so the one transitional turn reads as empty context.
context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
if context_tokens < 0:
context_tokens = 0
context_length = getattr(compressor, "context_length", 0) or 0
if context_length < 0:
context_length = 0
snapshot["context_tokens"] = context_tokens
snapshot["context_length"] = context_length or None
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
@@ -4865,6 +4896,10 @@ class HermesCLI:
if not self._ensure_runtime_credentials():
return False
from hermes_cli.mcp_startup import wait_for_mcp_discovery
wait_for_mcp_discovery()
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
if self._session_db is None:
try:
@@ -6693,10 +6728,21 @@ class HermesCLI:
if not target:
_cprint(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"):
# Arm a one-shot pending-resume selection so the user can type
# just the number (`3`) on the next line instead of having to
# retype `/resume 3`. The list here must match the one shown by
# _show_recent_sessions and used for index resolution below —
# all three go through _list_recent_sessions(limit=10). See
# #34584.
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
return
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
return
# Any explicit /resume <target> supersedes a previously-armed bare
# numbered prompt.
self._pending_resume_sessions = None
if not self._session_db:
from hermes_state import format_session_db_unavailable
_cprint(f" {format_session_db_unavailable()}")
@@ -6810,6 +6856,44 @@ class HermesCLI:
else:
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
def _consume_pending_resume_selection(self, text: str) -> bool:
"""Resolve a bare numeric reply that follows a bare ``/resume`` prompt.
After ``/resume`` (no args) prints the recent-sessions list it arms
``self._pending_resume_sessions``. The next submitted input is given
one chance to be a bare session number (``3``); if so we resume that
session here. Anything else (another command, free text, blank) simply
disarms the prompt and is handled normally by the caller.
Returns True if the input was consumed as a resume selection (caller
must not treat it as chat); False otherwise. The pending state is
always one-shot: it is cleared on the first submitted input regardless
of outcome. See #34584.
"""
pending = self._pending_resume_sessions
if not pending:
return False
# One-shot: disarm now so a non-matching input can't leave the prompt
# armed and hijack a later number the user meant as chat.
self._pending_resume_sessions = None
if not isinstance(text, str):
return False
stripped = text.strip()
# Only a pure number selects; let "/resume 3", titles, or any other
# text fall through to normal handling.
if not stripped.isdigit():
return False
index = int(stripped)
if index < 1 or index > len(pending):
_cprint(f" Resume index {index} is out of range.")
_cprint(" Use /resume with no arguments to see available sessions.")
return True
self._handle_resume_command(f"/resume {index}")
return True
def _handle_sessions_command(self, cmd_original: str) -> None:
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
@@ -8333,7 +8417,14 @@ class HermesCLI:
_base_word = cmd_lower.split()[0].lstrip("/")
_cmd_def = _resolve_cmd(_base_word)
canonical = _cmd_def.name if _cmd_def else _base_word
# A bare `/resume` prompt is one-shot: any command other than the
# resume/sessions handlers (which manage the pending state themselves)
# disarms it so a later number isn't swallowed as a stale selection.
# See #34584.
if canonical not in {"resume", "sessions"}:
self._pending_resume_sessions = None
if canonical in {"quit", "exit"}:
# Parse --delete flag: /exit --delete also removes the current
# session's transcripts + SQLite history. Ported from
@@ -9885,10 +9976,20 @@ class HermesCLI:
def _manual_compress(self, cmd_original: str = ""):
"""Manually trigger context compression on the current conversation.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else. Inspired by
Claude Code's ``/compact <focus>`` feature.
Two modes:
* ``/compress [<focus>]`` compress the *whole* history. An
optional focus topic guides the summariser to preserve
information related to *focus* while being more aggressive
about discarding everything else. Inspired by Claude Code's
``/compact <focus>`` feature.
* ``/compress here [N]`` boundary-aware compression. Summarize
everything *except* the most recent ``N`` exchanges (default
2), which are preserved verbatim. Inspired by Claude Code's
Rewind "Summarize up to here" action (v2.1.139, May 2026,
https://code.claude.com/docs/en/whats-new/2026-w20). Lets the
user pick the compression boundary instead of leaving it to
the automatic token-budget heuristic.
"""
if not self.conversation_history or len(self.conversation_history) < 4:
print("(._.) Not enough conversation to compress (need at least 4 messages).")
@@ -9902,12 +10003,21 @@ class HermesCLI:
print("(._.) Compression is disabled in config.")
return
# Extract optional focus topic from the command (e.g. "/compress database schema")
focus_topic = ""
from hermes_cli.partial_compress import (
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
# Args after the command word (e.g. "/compress here 3" -> "here 3").
raw_args = ""
if cmd_original:
parts = cmd_original.strip().split(None, 1)
if len(parts) > 1:
focus_topic = parts[1].strip()
_parts = cmd_original.strip().split(None, 1)
if len(_parts) > 1:
raw_args = _parts[1].strip()
partial, keep_last, focus_topic = parse_partial_compress_args(raw_args)
focus_topic = focus_topic or ""
original_count = len(self.conversation_history)
with self._busy_command("Compressing context..."):
@@ -9915,6 +10025,22 @@ class HermesCLI:
from agent.model_metadata import estimate_request_tokens_rough
from agent.manual_compression_feedback import summarize_manual_compression
original_history = list(self.conversation_history)
# Boundary-aware split: only the head is summarized; the
# most recent `keep_last` exchanges ride along verbatim.
tail: list = []
head = original_history
if partial:
head, tail = split_history_for_partial_compress(
original_history, keep_last
)
if not tail:
# Split degenerated (everything would be kept, or
# no head left to compress). Fall back to full
# compression so the user still gets an action.
partial = False
head = original_history
# Include system prompt + tool schemas in the estimate —
# a transcript-only number understates real request pressure
# and can even appear to grow after compression because a
@@ -9926,7 +10052,11 @@ class HermesCLI:
system_prompt=_sys_prompt,
tools=_tools,
)
if focus_topic:
if partial:
print(f"🗜️ Summarizing up to here: compressing {len(head)} of "
f"{original_count} messages (~{approx_tokens:,} tokens), "
f"keeping last {keep_last} exchange(s) verbatim...")
elif focus_topic:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
f"focus: \"{focus_topic}\"...")
else:
@@ -9939,12 +10069,21 @@ class HermesCLI:
# which already contain the agent identity — resulting in the
# identity block appearing twice (issue #15281).
compressed, _ = self.agent._compress_context(
original_history,
head,
None,
approx_tokens=approx_tokens,
focus_topic=focus_topic or None,
force=True,
)
# Re-append the verbatim tail after the compressed head.
# The split guarantees `tail` begins on a user turn, so the
# compressed-head -> tail boundary is normally valid
# (the head's compressed output ends on assistant/tool).
# rejoin_compressed_head_and_tail() additionally guards the
# seam against any illegal user->user / assistant->assistant
# adjacency, defending provider role-alternation rules.
if partial and tail:
compressed = rejoin_compressed_head_and_tail(compressed, tail)
self.conversation_history = compressed
# _compress_context ends the old session and creates a new child
# session on the agent (run_agent.py::_compress_context). Sync the
@@ -12835,6 +12974,13 @@ class HermesCLI:
if event.app.is_running:
event.app.exit()
event.app.current_buffer.reset(append_to_history=True)
# Force a repaint: process_command() prints through
# patch_stdout (scrolls output above the prompt) and never
# invalidates the app, so the just-cleared input area can
# keep showing the submitted text until some unrelated
# redraw fires. Every other early-return branch in this
# handler invalidates after reset — match them.
event.app.invalidate()
return
# Handle /steer while the agent is running immediately on the
@@ -12846,6 +12992,13 @@ class HermesCLI:
if self._should_handle_steer_command_inline(text, has_images=has_images):
self.process_command(text)
event.app.current_buffer.reset(append_to_history=True)
# Force a repaint after clearing the buffer. /steer is
# dispatched mid-run while the agent streams output through
# patch_stdout; process_command() never invalidates the
# app, so without this the submitted "/steer <text>" can
# linger in the input area (looking unsent) and invite an
# accidental re-submit. See issue #34569.
event.app.invalidate()
return
# Snapshot and clear attached images
@@ -13979,7 +14132,12 @@ class HermesCLI:
reserved_below = 6
available = max(0, term_rows - reserved_below)
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
# The compact decision must reserve room for at least one question
# row on top of the choices, otherwise full chrome (3 blank
# separators) gets kept when there is no room for it and the panel
# overflows the viewport — HSplit then clips the panel's tail,
# silently dropping the choices (the reported bug).
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
use_compact_chrome = mandatory_full > available
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
@@ -13987,9 +14145,24 @@ class HermesCLI:
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
# When the choices alone (plus compact chrome) already exceed the
# viewport, drop the question entirely — the choices are the only
# thing the user must see to make a selection. Without this the
# question would still claim its 1-row floor above and push the
# tail of the choices off-screen (HSplit clips the overflow).
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
if choices_overflow:
max_question_rows = 0
question_wrapped = _wrap_panel_text(question, inner_text_width)
if len(question_wrapped) > max_question_rows:
keep = max(1, max_question_rows - 1)
if max_question_rows <= 0:
question_wrapped = []
elif len(question_wrapped) > max_question_rows:
# The truncation marker is itself a row, so it must count
# against the budget. With a 1-row budget there is no room for
# both a question line and the marker — show the marker alone
# so the rendered question never exceeds max_question_rows.
keep = max(0, max_question_rows - 1)
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
lines = []
@@ -14523,6 +14696,17 @@ class HermesCLI:
+ (f"\n{_remainder}" if _remainder else "")
)
# A bare number right after a bare `/resume` prompt selects
# that session (see #34584). Checked before chat routing so
# the digit isn't sent to the agent as a message.
if (
not _file_drop
and self._pending_resume_sessions
and isinstance(user_input, str)
and self._consume_pending_resume_selection(user_input)
):
continue
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
_cprint(f"\n⚙️ {user_input}")
try:
@@ -14899,6 +15083,96 @@ class HermesCLI:
# Main Entry Point
# ============================================================================
def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
"""Drive a kanban goal_mode worker through the Ralph-style goal loop.
Called from the quiet single-query path AFTER the worker's first turn,
only when ``HERMES_KANBAN_GOAL_MODE`` is set (dispatcher-spawned
goal_mode card). Wires the worker's ``run_conversation`` and the kanban
DB into ``goals.run_kanban_goal_loop``. All errors are swallowed by the
caller a broken goal loop must never wedge a worker, the dispatcher's
claim TTL / crash detection is the backstop.
"""
import os as _os
task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
if not task_id:
return
from hermes_cli import kanban_db as _kb
from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS
# Resolve goal text from the card (title + body = the acceptance
# criteria the judge evaluates against).
conn = _kb.connect()
try:
task = _kb.get_task(conn, task_id)
finally:
try:
conn.close()
except Exception:
pass
if task is None:
return
goal_parts = [task.title or ""]
if task.body:
goal_parts.append(task.body)
goal_text = "\n\n".join(p for p in goal_parts if p).strip()
if not goal_text:
return
max_turns = task.goal_max_turns or _DEF_TURNS
def _run_turn(prompt: str) -> str:
result = cli.agent.run_conversation(
user_message=prompt,
conversation_history=cli.conversation_history,
)
# Keep session_id in sync if mid-run compression rotated it.
if (
getattr(cli.agent, "session_id", None)
and cli.agent.session_id != cli.session_id
):
cli.session_id = cli.agent.session_id
resp = result.get("final_response", "") if isinstance(result, dict) else str(result)
if resp:
print(resp)
return resp or ""
def _task_status() -> "str | None":
c = _kb.connect()
try:
t = _kb.get_task(c, task_id)
return t.status if t is not None else None
finally:
try:
c.close()
except Exception:
pass
def _block(reason: str) -> None:
c = _kb.connect()
try:
_kb.block_task(c, task_id, reason=reason)
finally:
try:
c.close()
except Exception:
pass
_run_loop(
task_id=task_id,
goal_text=goal_text,
run_turn=_run_turn,
task_status_fn=_task_status,
block_fn=_block,
max_turns=max_turns,
first_response=first_response or "",
log=lambda m: logger.info("%s", m),
)
def main(
query: str = None,
q: str = None,
@@ -15296,6 +15570,20 @@ def main(
print(f"Error: {result['error']}", file=sys.stderr)
elif response:
print(response)
# Kanban goal-loop mode: a worker spawned for a
# goal_mode card keeps working in THIS session until an
# auxiliary judge agrees the card is done, the worker
# terminates the task itself, or the turn budget runs
# out (→ sticky block). Gated on the env vars the
# dispatcher sets in `_default_spawn`; a no-op for every
# normal worker and every non-kanban `-q` run.
if os.environ.get("HERMES_KANBAN_GOAL_MODE") == "1":
try:
_run_kanban_goal_loop_q(cli, response)
except Exception as _goal_exc:
logger.debug("kanban goal loop failed: %s", _goal_exc)
# Session ID goes to stderr so piped stdout is clean.
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
+43 -5
View File
@@ -474,6 +474,13 @@ class GatewayConfig:
# Delivery settings
always_log_local: bool = True # Always save cron outputs to local files
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
# ".") pre-send. These are model hallucinations emitted when a persona has
# nothing actionable to say; in bot-to-bot channels they mirror back and
# forth, burning tokens and crashing models. Substrate-level guard that
# survives SOUL.md/prompt drift across providers. Opt out with False for
# raw passthrough.
filter_silence_narration: bool = True
# STT settings
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
@@ -582,6 +589,7 @@ class GatewayConfig:
"quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local,
"filter_silence_narration": self.filter_silence_narration,
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
@@ -650,6 +658,9 @@ class GatewayConfig:
quick_commands=quick_commands,
sessions_dir=sessions_dir,
always_log_local=_coerce_bool(data.get("always_log_local"), True),
filter_silence_narration=_coerce_bool(
data.get("filter_silence_narration"), True
),
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
@@ -757,21 +768,32 @@ def load_gateway_config() -> GatewayConfig:
if "always_log_local" in yaml_cfg:
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
if "filter_silence_narration" in yaml_cfg:
gw_data["filter_silence_narration"] = yaml_cfg[
"filter_silence_narration"
]
if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
yaml_cfg.get("unauthorized_dm_behavior"),
"pair",
)
# Merge platforms section from config.yaml into gw_data so that
# nested keys like platforms.webhook.extra.routes are loaded.
yaml_platforms = yaml_cfg.get("platforms")
# Merge platform config into gw_data so runtime-only settings under
# ``gateway.platforms`` are loaded the same way as top-level
# ``platforms``. Merge nested first so top-level config keeps
# precedence, matching the existing gateway.streaming fallback.
gateway_cfg = yaml_cfg.get("gateway")
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
platforms_data = gw_data.setdefault("platforms", {})
if not isinstance(platforms_data, dict):
platforms_data = {}
gw_data["platforms"] = platforms_data
if isinstance(yaml_platforms, dict):
for plat_name, plat_block in yaml_platforms.items():
def _merge_platform_map(source_platforms: Any) -> None:
if not isinstance(source_platforms, dict):
return
for plat_name, plat_block in source_platforms.items():
if not isinstance(plat_block, dict):
continue
existing = platforms_data.get(plat_name, {})
@@ -785,6 +807,10 @@ def load_gateway_config() -> GatewayConfig:
if merged_extra:
merged["extra"] = merged_extra
platforms_data[plat_name] = merged
_merge_platform_map(gateway_platforms)
_merge_platform_map(yaml_cfg.get("platforms"))
if platforms_data:
gw_data["platforms"] = platforms_data
# Iterate built-in platforms plus any registered plugin platforms
# so plugin authors get the same shared-key bridging (#24836).
@@ -890,6 +916,18 @@ def load_gateway_config() -> GatewayConfig:
if entry.apply_yaml_config_fn is None:
continue
platform_cfg = yaml_cfg.get(entry.name)
# Fall back to the platform's block under ``platforms`` /
# ``gateway.platforms`` so adapter hooks still run when the
# user configured the platform only under those nested paths
# (e.g. ``platforms.discord.extra.allow_from``) and not via a
# top-level ``discord:`` block.
if not isinstance(platform_cfg, dict):
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
if isinstance(_src, dict):
_candidate = _src.get(entry.name)
if isinstance(_candidate, dict):
platform_cfg = _candidate
break
if not isinstance(platform_cfg, dict):
continue
try:
+61
View File
@@ -9,6 +9,8 @@ Routes messages to the appropriate destination based on:
"""
import logging
import os
import re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
@@ -21,6 +23,32 @@ logger = logging.getLogger(__name__)
MAX_PLATFORM_OUTPUT = 4000
TRUNCATED_VISIBLE = 3800
# Matches strings that are *only* a "silence" narration with optional markdown
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
# wild. Anchored to start/end so substantive messages that merely *contain* the
# word "silent" are never matched.
_SILENCE_NARRATION = re.compile(
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
re.IGNORECASE,
)
def _is_silence_narration(content: Optional[str]) -> bool:
"""Return True when ``content`` is *only* a silence-narration token.
Length-guarded (real messages are longer) and anchored to the whole string
so legitimate prose like "The deployment ran silently" or "Silence is
golden here is the plan..." is never flagged.
"""
if not content:
return False
stripped = content.strip()
if not stripped or len(stripped) > 64: # length guard
return False
return bool(_SILENCE_NARRATION.match(stripped))
from .config import Platform, GatewayConfig
from .session import SessionSource
@@ -261,6 +289,18 @@ class DeliveryRouter:
path.write_text(content)
return path
def _filter_silence_narration_enabled(self) -> bool:
"""Whether the outbound silence-narration filter is active.
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
otherwise the ``gateway.filter_silence_narration`` config flag wins
(default True).
"""
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
return bool(getattr(self.config, "filter_silence_narration", True))
async def _deliver_to_platform(
self,
target: DeliveryTarget,
@@ -286,6 +326,27 @@ class DeliveryRouter:
+ f"\n\n... [truncated, full output saved to {saved_path}]"
)
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
# In bot-to-bot channels these tokens mirror back and forth until a
# model crashes with "no content after all retries". Behavioral prompt
# rules drift across providers; this single chokepoint covers every
# platform adapter regardless of which persona's prompt failed.
# Local/file delivery (_deliver_local) is a separate path and is never
# filtered — saved silence has no loop risk.
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
logger.warning(
"Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value,
target.chat_id,
content[:40],
)
return {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
send_metadata = dict(metadata or {})
is_named_telegram_private_topic = False
named_telegram_private_topic_name: Optional[str] = None
+40
View File
@@ -1605,6 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
)
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
await queue.put(_event_payload("assistant.completed", {
"session_id": effective_session_id,
"message_id": message_id,
@@ -1617,6 +1618,7 @@ class APIServerAdapter(BasePlatformAdapter):
"session_id": effective_session_id,
"message_id": message_id,
"completed": True,
"messages": turn_messages,
"usage": usage,
}))
except Exception as exc:
@@ -3329,6 +3331,44 @@ class APIServerAdapter(BasePlatformAdapter):
return len(prior)
return 0
@classmethod
def _turn_transcript_messages(
cls,
conversation_history: List[Dict[str, Any]],
user_message: Any,
result: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Return this turn's assistant/tool messages in client-safe shape.
The streaming SSE contract delivers all assistant text as
``assistant.delta`` events under one ``message_id`` interleaved with
``tool.*`` events, and a single ``assistant.completed`` carrying only
the final reply. A client that accumulates deltas into one buffer
cannot reconstruct *intermediate* assistant text segments that preceded
tool calls so when the page is re-opened mid/post-stream those
segments appear lost, even though state.db persisted them correctly.
Emitting the authoritative per-turn transcript on ``run.completed`` lets
any SSE consumer reconcile its live view against ground truth without a
separate ``GET /messages`` round-trip. Purely additive: clients that
ignore the field are unaffected. Refs #34703.
"""
agent_messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(agent_messages, list) or not agent_messages:
return []
start = cls._response_messages_turn_start_index(
conversation_history, user_message, result
)
turn = agent_messages[start:]
out: List[Dict[str, Any]] = []
for msg in turn:
if not isinstance(msg, dict):
continue
if msg.get("role") not in {"assistant", "tool"}:
continue
out.append(cls._message_response(msg))
return out
@staticmethod
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
"""
+106 -37
View File
@@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
@@ -827,6 +827,7 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
_HERMES_HOME = get_hermes_home()
_HERMES_ROOT = get_default_hermes_root()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
@@ -954,11 +955,14 @@ def _media_delivery_denied_paths() -> List[Path]:
home = Path(os.path.expanduser("~"))
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
denied.append(home / sub)
# The Hermes home itself contains credentials (auth.json, .env) — only the
# cache subdirectories under it are explicitly allowlisted above.
denied.append(_HERMES_HOME / ".env")
denied.append(_HERMES_HOME / "auth.json")
denied.append(_HERMES_HOME / "credentials")
# The active Hermes profile and shared Hermes root both contain control
# files and credentials. Only cache subdirectories under them are
# explicitly allowlisted above.
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
denied.append(hermes_root / ".env")
denied.append(hermes_root / "auth.json")
denied.append(hermes_root / "credentials")
denied.append(hermes_root / "config.yaml")
return denied
@@ -1131,6 +1135,77 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
}
# ---------------------------------------------------------------------------
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
#
# Both extractors that turn response text into native attachments derive their
# extension set from this tuple:
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
#
# Historically these two carried independently-maintained extension lists.
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
# ``extract_local_files`` had a broad one. Combined with the unconditional
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
# match, got stripped from the body by the loose cleanup regex, and was then
# invisible to extract_local_files — the file was never delivered (issue
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
# from the same set means a tag is only stripped when its extension is one we
# can actually deliver, so an unknown-extension path survives in the body
# instead of vanishing.
#
# Covers images (inline), video (inline where supported), audio (voice/audio),
# documents/spreadsheets/presentations (send_document), archives, and rendered
# web output. The dispatch partition (image vs video vs document) lives in
# ``gateway/run.py``.
# ---------------------------------------------------------------------------
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
# Images (embed inline)
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
# Video (embed inline where supported)
".mp4", ".mov", ".avi", ".mkv", ".webm",
# Audio (delivered as voice/audio where supported)
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
# Documents (uploaded as file attachments)
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
# Spreadsheets / data
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
# Presentations
".pptx", ".ppt", ".odp", ".key",
# Archives
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
# Web / rendered output
".html", ".htm",
)
# Regex alternation fragment of bare extensions (no leading dot), e.g.
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
# longest-first so the alternation never matches a shorter ext as a prefix of
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
_MEDIA_EXT_ALTERNATION = "|".join(
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
)
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
# an unknown extension is left in the text so it can still be picked up by the
# bare-path detector (extract_local_files) downstream rather than silently
# deleted. Shared by the non-streaming dispatch path and the streaming
# consumer so both behave identically.
# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),
# ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
MEDIA_TAG_CLEANUP_RE = re.compile(
r'''[`"']?MEDIA:\s*'''
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
re.IGNORECASE,
)
def get_document_cache_dir() -> Path:
"""Return the document cache directory, creating it if it doesn't exist."""
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -2542,10 +2617,10 @@ class BasePlatformAdapter(ABC):
cleaned = cleaned.replace("[[as_document]]", "")
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
# and quoted/backticked paths for LLM-formatted outputs.
media_pattern = re.compile(
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
)
# and quoted/backticked paths for LLM-formatted outputs. The extension
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
media_pattern = MEDIA_TAG_CLEANUP_RE
for match in media_pattern.finditer(content):
path = match.group("path").strip()
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
@@ -2591,31 +2666,15 @@ class BasePlatformAdapter(ABC):
Tuple of (list of expanded file paths, cleaned text with the
raw path strings removed).
"""
_LOCAL_MEDIA_EXTS = (
# Images (embed inline)
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
# Video (embed inline where supported)
'.mp4', '.mov', '.avi', '.mkv', '.webm',
# Audio (delivered as voice/audio where supported)
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
# Documents (uploaded as file attachments)
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
# Spreadsheets / data
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
# Presentations
'.pptx', '.ppt', '.odp', '.key',
# Archives
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
# Web / rendered output
'.html', '.htm',
)
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
# and relative paths (./foo.png)
# (?:~/|/) anchors to absolute or home-relative paths
# (?:~/|/) anchors to absolute or home-relative Unix paths
# (?:[A-Za-z]:[/\\]) anchors to Windows drive-letter paths (#34632)
path_re = re.compile(
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
re.IGNORECASE,
)
@@ -3681,6 +3740,7 @@ class BasePlatformAdapter(ABC):
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
is_ephemeral_response = isinstance(response, EphemeralReply)
# Slash-command handlers may return an EphemeralReply sentinel to
# request that their reply message auto-delete after a TTL (used
@@ -3729,16 +3789,25 @@ class BasePlatformAdapter(ABC):
# Strip any remaining internal directives from message body (fixes #1561)
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
text_content = text_content.replace("[[as_document]]", "").strip()
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
# Strip only MEDIA: tags whose path has a deliverable extension
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
# extension is intentionally left in the body so extract_local_files
# below can still pick up the bare path — otherwise the file would
# be silently dropped (issue #34517).
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax)
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
local_files = []
if not is_ephemeral_response:
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax). Skip
# system/command notices so config paths stay visible text
# instead of becoming native uploads.
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
+9 -1
View File
@@ -14,6 +14,7 @@ import logging
import os
import re
import uuid
from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import quote
@@ -60,6 +61,8 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
_PHONE_RE = re.compile(r"\+?\d{7,15}")
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
def _redact(text: str) -> str:
"""Redact phone numbers and emails from log output."""
@@ -128,7 +131,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
self._runner = None
self._private_api_enabled: Optional[bool] = None
self._helper_connected: bool = False
self._guid_cache: Dict[str, str] = {}
self._guid_cache: OrderedDict[str, str] = OrderedDict()
# ------------------------------------------------------------------
# API helpers
@@ -365,6 +368,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if ";" in target:
return target
if target in self._guid_cache:
self._guid_cache.move_to_end(target)
return self._guid_cache[target]
try:
payload = await self._api_post(
@@ -377,10 +381,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if identifier == target:
if guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
for part in chat.get("participants", []) or []:
if (part.get("address") or "").strip() == target and guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
except Exception:
pass
+30 -6
View File
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
from __future__ import annotations
import asyncio
import collections
import hashlib
import hmac
import itertools
@@ -239,6 +240,7 @@ _FEISHU_REACTION_FAILURE = "CrossMark"
# drain on completion; the cap is a safeguard against unbounded growth from
# delete-failures, not a capacity plan.
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
# QR onboarding constants
_ONBOARD_ACCOUNTS_URLS = {
@@ -1408,6 +1410,8 @@ class FeishuAdapter(BasePlatformAdapter):
"""Feishu/Lark bot adapter."""
MAX_MESSAGE_LENGTH = 8000
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
CHAT_LOCK_MAX_SIZE: int = 1000
# Threshold for detecting Feishu client-side message splits.
# When a chunk is near the ~4096-char practical limit, a continuation
# is almost certain.
@@ -1445,11 +1449,11 @@ class FeishuAdapter(BasePlatformAdapter):
self._pending_inbound_lock = threading.Lock()
self._pending_drain_scheduled = False
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
self._message_text_cache: Dict[str, Optional[str]] = {}
self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
self._app_lock_identity: Optional[str] = None
self._text_batch_state = FeishuBatchState()
self._pending_text_batches = self._text_batch_state.events
@@ -2835,11 +2839,28 @@ class FeishuAdapter(BasePlatformAdapter):
# =========================================================================
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
Bounded with LRU eviction so a long-running gateway that sees many
distinct chats does not grow ``_chat_locks`` without limit. Locks that
are currently held are never evicted; if every entry is locked we fall
back to dropping the least-recently-used one.
"""
lock = self._chat_locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
self._chat_locks[chat_id] = lock
if lock is not None:
self._chat_locks.move_to_end(chat_id)
return lock
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
evicted = False
for key in list(self._chat_locks):
if not self._chat_locks[key].locked():
self._chat_locks.pop(key)
evicted = True
break
if not evicted:
self._chat_locks.pop(next(iter(self._chat_locks)))
lock = asyncio.Lock()
self._chat_locks[chat_id] = lock
return lock
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
@@ -3939,6 +3960,7 @@ class FeishuAdapter(BasePlatformAdapter):
if not self._client or not message_id:
return None
if message_id in self._message_text_cache:
self._message_text_cache.move_to_end(message_id)
return self._message_text_cache[message_id]
try:
request = self._build_get_message_request(message_id)
@@ -3960,6 +3982,8 @@ class FeishuAdapter(BasePlatformAdapter):
mentions=parent_mentions,
)
self._message_text_cache[message_id] = text
while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
self._message_text_cache.popitem(last=False)
return text
except Exception:
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
+135 -25
View File
@@ -840,6 +840,41 @@ class TelegramAdapter(BasePlatformAdapter):
stack.append(context)
return False
@staticmethod
def _looks_like_pool_timeout(error: Exception) -> bool:
"""Return True when a Telegram TimedOut wraps an httpx pool timeout.
PTB converts ``httpx.PoolTimeout`` into ``telegram.error.TimedOut`` with
a message that explicitly states the request was *not* sent
(``"Pool timeout: All connections in the connection pool are occupied.
Request was *not* sent to Telegram."``). Because the request never left
the process, re-sending is safe and cannot duplicate -- the opposite of
a generic TimedOut, which may have reached Telegram. We match the
wrapped ``httpx.PoolTimeout`` class as well as the message string so the
check survives PTB message-wording changes.
"""
seen: set[int] = set()
stack: list[BaseException] = [error]
while stack:
cur = stack.pop()
ident = id(cur)
if ident in seen:
continue
seen.add(ident)
name = cur.__class__.__name__.lower()
text = str(cur).lower()
if "pooltimeout" in name or "pool timeout" in text or (
"connection pool" in text and "occupied" in text
):
return True
cause = getattr(cur, "__cause__", None)
context = getattr(cur, "__context__", None)
if cause is not None:
stack.append(cause)
if context is not None:
stack.append(context)
return False
def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
@@ -2001,11 +2036,15 @@ class TelegramAdapter(BasePlatformAdapter):
# TimedOut is also a subclass of NetworkError. A
# generic timeout may have reached Telegram, so don't
# retry; a wrapped ConnectTimeout means no connection
# was established, so retrying is safe.
# was established, so retrying is safe. A pool timeout
# (httpx pool exhausted) is explicitly "not sent to
# Telegram" -- retrying through the loop is safe and
# prevents silent drops when the pool frees up.
if (
_TimedOut
and isinstance(send_err, _TimedOut)
and not self._looks_like_connect_timeout(send_err)
and not self._looks_like_pool_timeout(send_err)
):
raise
if _send_attempt < 2:
@@ -2065,12 +2104,14 @@ class TelegramAdapter(BasePlatformAdapter):
return SendResult(success=False, error="message_too_long")
# TimedOut usually means the request may have reached Telegram —
# mark as non-retryable so _send_with_retry() doesn't re-send.
# Exception: wrapped ConnectTimeout, where no connection was
# established; retrying is safe and prevents silent drops.
# Exceptions: a wrapped ConnectTimeout (no connection established)
# and an httpx pool timeout (request explicitly not sent) -- both
# are safe to re-send and must not be silently dropped.
_to = locals().get("_TimedOut")
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
is_connect_timeout = self._looks_like_connect_timeout(e)
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))
is_pool_timeout = self._looks_like_pool_timeout(e)
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or is_pool_timeout or not is_timeout))
async def send_or_update_status(
self,
@@ -2804,21 +2845,8 @@ class TelegramAdapter(BasePlatformAdapter):
return slug
try:
# Build provider buttons — 2 per row
buttons: list = []
for p in providers:
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count})"
if p.get("is_current"):
label = f"{label}"
# Compact callback data: mp:<slug> (max 64 bytes)
buttons.append(
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
)
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
keyboard = InlineKeyboardMarkup(rows)
# Build provider buttons — folds provider groups (display only).
keyboard = self._build_provider_keyboard(providers)
provider_label = get_label(current_provider)
text = self.format_message(
@@ -2865,6 +2893,56 @@ class TelegramAdapter(BasePlatformAdapter):
_MODEL_PAGE_SIZE = 8
def _build_provider_keyboard(self, providers: list):
"""Build the top-level provider keyboard, folding provider groups.
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
a single ``mpg:<gid>`` button; tapping it drills into a member
sub-keyboard. Single providers (and groups with only one authenticated
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
so all surfaces stay consistent.
"""
try:
from hermes_cli.models import group_providers
except Exception:
group_providers = None
by_slug = {p.get("slug"): p for p in providers}
def _provider_button(p):
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count})"
if p.get("is_current"):
label = f"{label}"
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
buttons: list = []
if group_providers is not None:
for row in group_providers([p.get("slug") for p in providers]):
if row["kind"] == "group":
members = [by_slug[m] for m in row["members"] if m in by_slug]
count = sum(
m.get("total_models", len(m.get("models", []))) for m in members
)
label = f"{row['label']} ▸ ({count})"
if any(m.get("is_current") for m in members):
label = f"{label}"
buttons.append(
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
)
else:
p = by_slug.get(row["slug"])
if p is not None:
buttons.append(_provider_button(p))
else:
for p in providers:
buttons.append(_provider_button(p))
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
return InlineKeyboardMarkup(rows)
def _build_model_keyboard(self, models: list, page: int) -> tuple:
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
page_size = self._MODEL_PAGE_SIZE
@@ -3043,10 +3121,23 @@ class TelegramAdapter(BasePlatformAdapter):
# Clean up state
self._model_picker_state.pop(chat_id, None)
elif data == "mb":
# --- Back to provider list ---
elif data.startswith("mpg:"):
# --- Provider group selected: show member providers ---
group_id = data[4:]
try:
from hermes_cli.models import PROVIDER_GROUPS
_label, member_slugs = PROVIDER_GROUPS.get(group_id, ("", []))
except Exception:
_label, member_slugs = "", []
by_slug = {p["slug"]: p for p in state["providers"]}
members = [by_slug[m] for m in member_slugs if m in by_slug]
if not members:
await query.answer(text="Group not found.")
return
buttons = []
for p in state["providers"]:
for p in members:
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count})"
if p.get("is_current"):
@@ -3054,11 +3145,30 @@ class TelegramAdapter(BasePlatformAdapter):
buttons.append(
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
)
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
rows.append([
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
])
keyboard = InlineKeyboardMarkup(rows)
await query.edit_message_text(
text=self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Provider family: *{_label or group_id}*\n\n"
f"Select a provider:"
)
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer()
elif data == "mb":
# --- Back to provider list (folds groups) ---
keyboard = self._build_provider_keyboard(state["providers"])
try:
provider_label = get_label(state["current_provider"])
except Exception:
@@ -3107,7 +3217,7 @@ class TelegramAdapter(BasePlatformAdapter):
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
if chat_id:
await self._handle_model_picker_callback(query, data, chat_id)
+115 -6
View File
@@ -1180,12 +1180,48 @@ class WeixinAdapter(BasePlatformAdapter):
default=False,
)
# Text debounce batching (mirrors Telegram adapter pattern).
# iLink delivers messages individually, so rapid multi-message
# bursts (forwarded batches, paste-splits) each trigger a
# separate agent invocation. Default 3s delay / 5s split delay
# are tuned for iLink's typical delivery cadence. Tunable via
# config.yaml under
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 3.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 5.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
if self._account_id and not self._token:
persisted = load_weixin_account(hermes_home, self._account_id)
if persisted:
self._token = str(persisted.get("token") or "").strip()
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
@staticmethod
def _coerce_list(value: Any) -> List[str]:
if value is None:
@@ -1247,6 +1283,11 @@ class WeixinAdapter(BasePlatformAdapter):
async def disconnect(self) -> None:
_LIVE_ADAPTERS.pop(self._token, None)
self._running = False
for task in self._pending_text_batch_tasks.values():
if not task.done():
task.cancel()
self._pending_text_batches.clear()
self._pending_text_batch_tasks.clear()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
@@ -1395,12 +1436,10 @@ class WeixinAdapter(BasePlatformAdapter):
timestamp=datetime.now(),
)
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
await self.handle_message(event)
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
@@ -1409,6 +1448,76 @@ class WeixinAdapter(BasePlatformAdapter):
return sender_id in self._allow_from
return True
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Text debounce batching
# ------------------------------------------------------------------
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When users forward multiple messages or send rapid-fire texts
via WeChat, each arrives as a separate iLink message. This
concatenates them and waits for a short quiet period before
dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
if self._pending_text_batch_tasks.get(key) is not current_task:
return
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
item_type = item.get("type")
if item_type == ITEM_IMAGE:
+102 -2
View File
@@ -278,6 +278,43 @@ class WhatsAppAdapter(BasePlatformAdapter):
# notification before the normal "✓ whatsapp disconnected" fires.
self._shutting_down: bool = False
# Text debounce batching (mirrors Telegram adapter pattern).
# WhatsApp often delivers multiple messages in rapid succession
# (e.g. forwarded batches, paste-splits) — without debounce each
# message triggers a separate agent invocation, wasting tokens and
# flooding the user with reply fragments. Default 5s delay /
# 10s split delay are conservative for WhatsApp's delivery cadence.
# Tunable via config.yaml under
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 5.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 10.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
def _effective_reply_prefix(self) -> str:
"""Return the prefix the Node bridge will add in self-chat mode."""
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
@@ -1139,7 +1176,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
for msg_data in messages:
event = await self._build_message_event(msg_data)
if event:
await self.handle_message(event)
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
except asyncio.CancelledError:
break
except Exception as e:
@@ -1151,7 +1191,67 @@ class WhatsAppAdapter(BasePlatformAdapter):
await asyncio.sleep(5)
await asyncio.sleep(1) # Poll interval
# ── Text debounce batching ──────────────────────────────────────
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When WhatsApp delivers rapid-fire messages (e.g. forwarded
batches), this concatenates them and waits for a short quiet
period before dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
+319 -62
View File
@@ -1730,6 +1730,14 @@ class GatewayRunner:
self._running_agents: Dict[str, Any] = {}
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
# Last successfully-resolved (non-empty) model, keyed by session. Used
# as a fallback when a fresh config read transiently returns an empty
# model (e.g. an mtime-keyed config-cache miss during a post-interrupt
# recovery turn). Without this, the agent is built with model="" and
# every API call fails HTTP 400 "No models provided" — the session goes
# silent until the user manually re-sends. See #35314. ``"*"`` holds a
# process-wide last-known-good for sessions seen for the first time.
self._last_resolved_model: Dict[str, str] = {}
# Overflow buffer for explicit /queue commands. The adapter-level
# _pending_messages dict is a single slot per session (designed for
# "next-turn" follow-ups where repeated sends collapse into one
@@ -2488,6 +2496,32 @@ class GatewayRunner:
except Exception:
pass
# Final safety net (#35314): if resolution still produced an empty
# model — e.g. a transient config-cache miss during a post-interrupt
# recovery turn returned an empty user_config — reuse the last model we
# successfully resolved for this session (or, failing that, the most
# recent one resolved process-wide). Building an agent with model=""
# makes every API call fail HTTP 400 "No models provided" and the
# session goes silent until the user manually re-sends. ``getattr``
# guards against bare test runners built via ``object.__new__``.
_last_good = getattr(self, "_last_resolved_model", None)
if _last_good is not None:
if not model:
_recovered = _last_good.get(resolved_session_key or "") or _last_good.get("*")
if _recovered:
logger.warning(
"Empty model resolved for session=%s — recovering "
"last-known-good model %s (config read likely returned "
"empty; see #35314)",
resolved_session_key or "", _recovered,
)
model = _recovered
elif model:
# Cache the good resolution for future recovery turns.
if resolved_session_key:
_last_good[resolved_session_key] = model
_last_good["*"] = model
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
@@ -2784,10 +2818,12 @@ class GatewayRunner:
"""Mark a queued platform as paused — keep it in ``_failed_platforms``
but stop the reconnect watcher from hammering it.
Used by the circuit breaker after ``_PAUSE_AFTER_FAILURES`` consecutive
retryable failures, and by ``/platform pause <name>`` for manual
intervention. Paused platforms are surfaced in ``/platform list``
and resumed with ``/platform resume <name>``.
Used by ``/platform pause <name>`` for manual operator intervention.
Paused platforms are surfaced in ``/platform list`` and resumed with
``/platform resume <name>``. Note: the reconnect watcher does NOT
auto-pause retryable (network/DNS) failures keep retrying at the
backoff cap indefinitely so a transient outage self-heals without
manual intervention.
"""
info = getattr(self, "_failed_platforms", {}).get(platform)
if info is None:
@@ -4429,10 +4465,19 @@ class GatewayRunner:
# Drain any recovered process watchers (from crash recovery checkpoint)
try:
from tools.process_registry import process_registry
while process_registry.pending_watchers:
watcher = process_registry.pending_watchers.pop(0)
# Detach the current batch atomically: reassigning to a fresh list
# takes ownership of exactly the watchers present now, so any watcher
# appended concurrently during the yield below isn't silently dropped
# by a clear() on the shared list.
watchers = process_registry.pending_watchers
process_registry.pending_watchers = []
# Process in batches of 100 with event-loop yield points to avoid
# O(n^2) event-loop blocking when recovering thousands of watchers.
for i, watcher in enumerate(watchers):
asyncio.create_task(self._run_process_watcher(watcher))
logger.info("Resumed watcher for recovered process %s", watcher.get("session_id"))
if i % 100 == 99:
await asyncio.sleep(0)
except Exception as e:
logger.error("Recovered watcher setup error: %s", e)
@@ -5865,15 +5910,17 @@ class GatewayRunner:
"""Background task that periodically retries connecting failed platforms.
Uses exponential backoff: 30s 60s 120s 240s 300s (cap).
Retryable failures keep retrying at the backoff cap indefinitely
but if a platform fails ``_PAUSE_AFTER_FAILURES`` times in a row
without ever succeeding, it is *paused*: kept in the retry queue
but no longer hammered. The user surfaces it with ``/platform list``
and resumes it with ``/platform resume <name>``. Non-retryable
failures (bad auth, etc.) still drop out of the queue immediately.
Retryable failures (network/DNS blips) keep retrying at the backoff
cap indefinitely they self-heal once connectivity returns, so a
transient outage never requires manual intervention. Non-retryable
failures (bad auth, etc.) drop out of the queue immediately. The
circuit breaker (``_pause_failed_platform`` / ``/platform pause``)
remains available for manual operator control via ``/platform list``
and ``/platform resume <name>``, but is no longer triggered
automatically auto-pausing a recovered platform was the cause of
bots silently staying dead after a transient DNS failure.
"""
_BACKOFF_CAP = 300 # 5 minutes max between retries
_PAUSE_AFTER_FAILURES = 10 # circuit-breaker threshold
await asyncio.sleep(10) # initial delay — let startup finish
while self._running:
@@ -5968,14 +6015,14 @@ class GatewayRunner:
"Reconnect %s failed, next retry in %ds",
platform.value, backoff,
)
if attempt >= _PAUSE_AFTER_FAILURES:
self._pause_failed_platform(
platform,
reason=(
adapter.fatal_error_message
or "failed to reconnect"
),
)
# Retryable failures (network/DNS blips) keep retrying
# at the backoff cap indefinitely — they self-heal once
# connectivity returns. We do NOT auto-pause them: a
# transient outage must never require manual `/platform
# resume` to recover. Non-retryable failures (bad auth,
# etc.) already drop out of the queue via the
# `not fatal_error_retryable` branch above, so anything
# reaching here is by definition retryable.
except Exception as e:
self._update_platform_runtime_status(
platform.value,
@@ -5990,8 +6037,9 @@ class GatewayRunner:
"Reconnect %s error: %s, next retry in %ds",
platform.value, e, backoff,
)
if attempt >= _PAUSE_AFTER_FAILURES:
self._pause_failed_platform(platform, reason=str(e))
# A raised exception during reconnect (connect timeout, DNS
# resolution failure, etc.) is inherently transient — keep
# retrying at the backoff cap rather than auto-pausing.
# Check every 10 seconds for platforms that need reconnection
for _ in range(10):
@@ -7899,7 +7947,7 @@ class GatewayRunner:
result = await result
return str(result) if result else None
except Exception as e:
logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
logger.warning("Plugin command dispatch failed: %s", e)
# Skill slash commands: /skill-name loads the skill and sends to agent.
# resolve_skill_command_key() handles the Telegram underscore/hyphen
@@ -7931,7 +7979,7 @@ class GatewayRunner:
)
# Fall through to normal message processing with bundle content
except Exception as exc:
logger.debug("Bundle dispatch failed (non-fatal): %s", exc)
logger.warning("Bundle dispatch failed: %s", exc)
if command and not locals().get("_bundle_handled", False):
try:
@@ -9122,9 +9170,15 @@ class GatewayRunner:
# Check for pending process watchers (check_interval on background processes)
try:
from tools.process_registry import process_registry
while process_registry.pending_watchers:
watcher = process_registry.pending_watchers.pop(0)
# Detach the current batch atomically (see crash-recovery drain
# above): reassign to a fresh list so a watcher appended by a
# concurrent session during the yield isn't dropped by clear().
watchers = process_registry.pending_watchers
process_registry.pending_watchers = []
for i, watcher in enumerate(watchers):
asyncio.create_task(self._run_process_watcher(watcher))
if i % 100 == 99:
await asyncio.sleep(0)
except Exception as e:
logger.error("Process watcher setup error: %s", e)
@@ -10070,6 +10124,45 @@ class GatewayRunner:
return "\n".join(lines)
def _sibling_thread_run_keys(self, source: SessionSource, own_key: str) -> list:
"""Find running-agent keys for OTHER participants in the same thread.
Only applies when the message originates in a thread. In per-user
thread mode (``thread_sessions_per_user=True``) each participant gets
an isolated session key of the form
``agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}:{user_id}``,
so a run started by another user is invisible to the caller's own
``/stop``. This returns the keys of any *actually running* agents
(not the pending sentinel, not the caller's own key) whose key shares
the caller's ``{chat_id}:{thread_id}`` prefix.
Returns an empty list when the source is not in a thread, or when no
sibling runs exist callers must still gate on authorization.
"""
thread_id = getattr(source, "thread_id", None)
chat_id = getattr(source, "chat_id", None)
if not thread_id or not chat_id:
return []
platform = source.platform.value
chat_type = getattr(source, "chat_type", None) or ""
# Prefix that every per-user key in this thread shares, up to and
# including the thread_id segment. Matching either the exact
# shared-thread key or any key with a further (user_id) segment
# (prefix + ":") avoids cross-matching an unrelated thread whose id
# merely starts with this one.
prefix = ":".join(
["agent:main", platform, chat_type, str(chat_id), str(thread_id)]
)
matches = []
for key, agent in list(self._running_agents.items()):
if key == own_key:
continue
if agent is _AGENT_PENDING_SENTINEL or not agent:
continue
if key == prefix or key.startswith(prefix + ":"):
matches.append(key)
return matches
async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /stop command - interrupt a running agent.
@@ -10106,8 +10199,31 @@ class GatewayRunner:
invalidation_reason="stop_command_handler",
)
return EphemeralReply(t("gateway.stop.stopped"))
else:
return t("gateway.stop.no_active")
# No run under the caller's own session key. In a per-user thread
# (thread_sessions_per_user=True) each participant is isolated even
# inside one shared thread, so a run another user started lives under
# a different key. Authorized users should still be able to /stop it
# (#bernard-thread-stop). Fall back to interrupting any running
# agent(s) that share this thread, gated on authorization.
sibling_keys = self._sibling_thread_run_keys(source, session_key)
if sibling_keys and self._is_user_authorized(source):
for sibling_key in sibling_keys:
await self._interrupt_and_clear_session(
sibling_key,
source,
interrupt_reason=_INTERRUPT_REASON_STOP,
invalidation_reason="stop_command_thread_sibling",
)
logger.info(
"STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s",
session_key,
len(sibling_keys),
", ".join(sibling_keys),
)
return EphemeralReply(t("gateway.stop.stopped"))
return t("gateway.stop.no_active")
async def _handle_platform_command(self, event: MessageEvent) -> str:
"""Handle ``/platform list|pause|resume [name]`` — surface and
@@ -10549,6 +10665,22 @@ class GatewayRunner:
except Exception as exc:
logger.warning("Picker model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the
# dashboard shows the updated model (#34850).
_sess_db = getattr(_self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = _self.session_store.get_or_create_session(
event.source
)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store model note + session override
if not hasattr(_self, "_pending_model_notes"):
_self._pending_model_notes = {}
@@ -10686,6 +10818,20 @@ class GatewayRunner:
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
@@ -11743,9 +11889,16 @@ class GatewayRunner:
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
media_files, _ = adapter.extract_media(response)
media_files, cleaned = adapter.extract_media(response)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
_, cleaned = adapter.extract_images(response)
# Chain the cleaned text through each extractor (extract_media →
# extract_images → extract_local_files) so MEDIA: tags and image URLs
# are removed before the bare-path auto-detect runs. Previously the
# cleaned text from extract_media was dropped (``_``) and
# extract_local_files scanned text that still contained MEDIA: tags,
# producing false-positive bare-path matches with the MEDIA: prefix
# glued on. This matches the chain order in gateway/platforms/base.py.
_, cleaned = adapter.extract_images(cleaned)
local_files, _ = adapter.extract_local_files(cleaned)
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
@@ -12442,6 +12595,12 @@ class GatewayRunner:
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else.
Also accepts the boundary-aware form ``/compress here [N]``:
summarize everything except the most recent ``N`` exchanges
(default 2), kept verbatim. Inspired by Claude Code's Rewind
"Summarize up to here" action (v2.1.139, May 2026,
https://code.claude.com/docs/en/whats-new/2026-w20).
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
@@ -12450,8 +12609,15 @@ class GatewayRunner:
if not history or len(history) < 4:
return t("gateway.compress.not_enough")
# Extract optional focus topic from command args
focus_topic = (event.get_command_args() or "").strip() or None
# Parse args: either a focus topic (full compress) or the
# boundary-aware "here [N]" form (partial compress).
from hermes_cli.partial_compress import (
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
_raw_args = (event.get_command_args() or "").strip()
partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args)
try:
from run_agent import AIAgent
@@ -12472,6 +12638,19 @@ class GatewayRunner:
if m.get("role") in {"user", "assistant"} and m.get("content")
]
# Boundary-aware split: only the head is summarized; the most
# recent `keep_last` exchanges are preserved verbatim. The
# split snaps the tail to a user-turn start so the rejoined
# transcript keeps role alternation valid.
tail: list = []
head = msgs
if partial:
head, tail = split_history_for_partial_compress(msgs, keep_last)
if not tail:
# Degenerate split — fall back to full compression.
partial = False
head = msgs
tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
@@ -12495,15 +12674,20 @@ class GatewayRunner:
)
compressor = tmp_agent.context_compressor
if not compressor.has_content_to_compress(msgs):
if not compressor.has_content_to_compress(head):
return t("gateway.compress.nothing_to_do")
loop = asyncio.get_running_loop()
compressed, _ = await loop.run_in_executor(
None,
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
)
# Re-append the verbatim tail after the compressed head,
# guarding the seam against illegal role adjacency.
if partial and tail:
compressed = rejoin_compressed_head_and_tail(compressed, tail)
# _compress_context already calls end_session() on the old session
# (preserving its full transcript in SQLite) and creates a new
# session_id for the continuation. Write the compressed messages
@@ -15293,8 +15477,52 @@ class GatewayRunner:
("compression", "target_ratio"),
("compression", "protect_last_n"),
("agent", "disabled_toolsets"),
("memory", "provider"),
)
_HONCHO_CACHE_BUSTING_KEYS = (
"honcho.peer_name",
"honcho.ai_peer",
"honcho.pin_peer_name",
"honcho.runtime_peer_prefix",
"honcho.user_peer_aliases",
)
_HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {}
@classmethod
def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]:
return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS}
@classmethod
def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]:
"""Extract Honcho identity keys, memoized by honcho.json mtime."""
try:
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
path = resolve_config_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = None
memo_key = (str(path), mtime_ns)
cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key)
if cached is not None:
return dict(cached)
hcfg = HonchoClientConfig.from_global_config(config_path=path)
aliases = hcfg.user_peer_aliases or {}
values = {
"honcho.peer_name": hcfg.peer_name,
"honcho.ai_peer": hcfg.ai_peer,
"honcho.pin_peer_name": bool(hcfg.pin_peer_name),
"honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "",
"honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [],
}
cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values}
return dict(values)
except Exception:
return cls._empty_honcho_cache_busting_config()
@classmethod
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
"""Pull values that must bust the cached agent.
@@ -15325,26 +15553,12 @@ class GatewayRunner:
out["tools.registry_generation"] = None
# Honcho identity-mapping keys live in honcho.json, not user_config.
# HonchoSessionManager freezes the resolved peer_name / ai_peer /
# pin / aliases / prefix at construction; without busting here,
# mid-flight honcho.json edits go unread until the next unrelated
# cache eviction.
try:
from plugins.memory.honcho.client import HonchoClientConfig
hcfg = HonchoClientConfig.from_global_config()
out["honcho.peer_name"] = hcfg.peer_name
out["honcho.ai_peer"] = hcfg.ai_peer
out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name)
out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or ""
aliases = hcfg.user_peer_aliases or {}
out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else []
except Exception:
out["honcho.peer_name"] = None
out["honcho.ai_peer"] = None
out["honcho.pin_peer_name"] = None
out["honcho.runtime_peer_prefix"] = None
out["honcho.user_peer_aliases"] = None
# Only read that file when Honcho is the active memory provider.
provider = cfg_get(cfg, "memory", "provider")
if isinstance(provider, str) and provider.lower() == "honcho":
out.update(cls._extract_honcho_cache_busting_config())
else:
out.update(cls._empty_honcho_cache_busting_config())
return out
@@ -17183,7 +17397,7 @@ class GatewayRunner:
_hc = _hm.get("content", "")
if "MEDIA:" in _hc:
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
@@ -17478,18 +17692,38 @@ class GatewayRunner:
# append any that aren't already present in the final response, so the
# adapter's extract_media() can find and deliver the files exactly once.
#
# Uses path-based deduplication against _history_media_paths (collected
# before run_conversation) instead of index slicing. This is safe even
# when context compression shrinks the message list. (Fixes #160)
# Scope the scan to THIS turn's tool results only. ``agent_history``
# was passed into run_conversation as ``conversation_history``, so the
# agent's returned ``messages`` list is ``agent_history`` followed by
# the messages produced this turn. Slicing at ``len(agent_history)``
# isolates the current turn precisely, so a stale MEDIA: path emitted
# by a tool several turns earlier (still present in the full message
# list) can never leak onto a later text-only reply. (Fixes #34608)
#
# Path-based deduplication against _history_media_paths (collected
# before run_conversation) is retained as a secondary guard. It is
# also the sole guard on the fallback branch taken when mid-run
# context compression shrinks the message list below the original
# history length, preserving the compression-safe behaviour of #160.
if "MEDIA:" not in final_response:
media_tags = []
has_voice_directive = False
for msg in result.get("messages", []):
_all_msgs = result.get("messages", [])
_history_len = len(agent_history)
# Only trust the slice boundary when the message list still
# contains the full history prefix. Mid-run compression can
# rewrite/shrink the list; in that case fall back to scanning
# everything and rely on _history_media_paths for dedup.
if _history_len and len(_all_msgs) >= _history_len:
_scan_msgs = _all_msgs[_history_len:]
else:
_scan_msgs = _all_msgs
for msg in _scan_msgs:
if msg.get("role") in {"tool", "function"}:
content = msg.get("content", "")
if "MEDIA:" in content:
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
@@ -18442,7 +18676,10 @@ def _run_planned_stop_watcher(
poll_interval: seconds between marker checks. 0.5s gives a
responsive shutdown without burning CPU.
"""
from gateway.status import _get_planned_stop_marker_path
from gateway.status import (
_get_planned_stop_marker_path,
planned_stop_marker_targets_self,
)
marker_path = _get_planned_stop_marker_path()
while not stop_event.is_set():
try:
@@ -18451,6 +18688,26 @@ def _run_planned_stop_watcher(
and not getattr(runner, "_draining", False)
and getattr(runner, "_running", False)
):
# A marker existing is NOT sufficient — it may have been
# written for a PREVIOUS gateway instance (different PID)
# and left behind because that process exited before the
# CLI's stop() could clean it up. Firing the handler on a
# stale/foreign marker drives the gateway into shutdown,
# then consume_planned_stop_marker_for_self() correctly
# reports a PID mismatch — but by then we're already
# stopping, so it's logged as an unexpected "UNKNOWN" exit
# and the watchdog crash-loops the gateway (issue #34597,
# a regression from PR #33798 which added this watcher
# without the PID check).
#
# Only fire when the marker actually targets us. The probe
# is non-destructive on a match (the handler does the
# authoritative consume on the loop thread) and self-heals
# by unlinking stale/malformed markers so they cannot wedge
# a freshly booted gateway.
if not planned_stop_marker_targets_self():
stop_event.wait(poll_interval)
continue
# Drive the same path as a real signal handler.
# Pass signal=None — the handler tolerates that and consumes
# the marker via consume_planned_stop_marker_for_self,
+80 -6
View File
@@ -816,12 +816,24 @@ def _consume_pid_marker_for_self(
our_pid = os.getpid()
our_start_time = _get_process_start_time(our_pid)
matches = (
target_pid == our_pid
and target_start_time is not None
and our_start_time is not None
and target_start_time == our_start_time
)
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform the planned-stop watcher exists for). Requiring a non-None
# match there would make every consume return False, so a legitimate
# ``hermes gateway stop`` on Windows would be misclassified as an
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
# manager. So: when both start_times are known they must match; when
# either is unknown, fall back to PID equality alone (bounded by the
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
# so the watcher's non-destructive probe and this authoritative
# consume agree on every platform (issue #34597).
if target_pid != our_pid:
matches = False
elif target_start_time is not None and our_start_time is not None:
matches = target_start_time == our_start_time
else:
matches = True
try:
path.unlink(missing_ok=True)
@@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool:
)
def planned_stop_marker_targets_self() -> bool:
"""Return True only when a live planned-stop marker names the current process.
This is a **non-destructive** probe used by the watcher thread
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
it never unlinks a marker that matches us the shutdown handler does
the authoritative consume on its own thread.
It *does* clean up markers that can never apply to this process:
malformed markers and markers older than the TTL are unlinked so a
stale file left behind by a previous gateway instance cannot wedge
the new one. Markers naming a different PID/start_time are left in
place (they may still be consumed legitimately by the process they
name) but report False here.
Returns False (without raising) on any read/parse error.
"""
path = _get_planned_stop_marker_path()
record = _read_json_file(path)
if not record:
return False
try:
target_pid = int(record["target_pid"])
target_start_time = record.get("target_start_time")
written_at = record.get("written_at") or ""
except (KeyError, TypeError, ValueError):
# Malformed marker can never match anyone — drop it.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
# A marker this old is past its useful life regardless of target —
# clean it up so it cannot crash-loop a freshly booted gateway.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
our_pid = os.getpid()
if target_pid != our_pid:
return False
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform this watcher exists for). Requiring a non-None match there
# would make the watcher never fire and re-break the #33778 Windows
# session-resume path. So: when both start_times are known they must
# match; when either is unknown, fall back to PID equality alone
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
our_start_time = _get_process_start_time(our_pid)
if target_start_time is not None and our_start_time is not None:
return target_start_time == our_start_time
return True
def clear_planned_stop_marker() -> None:
"""Remove the planned-stop marker unconditionally."""
try:
+8 -4
View File
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
from gateway.platforms.base import _custom_unit_to_cp
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
from gateway.config import (
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
@@ -645,10 +646,13 @@ class GatewayStreamConsumer:
except Exception as e:
logger.error("Stream consumer error: %s", e)
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
# Matches the simple cleanup regex used by the non-streaming path in
# gateway/platforms/base.py for post-processing.
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
# Strip MEDIA:<path> tags before display. Uses the shared anchored
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
# path ends in a deliverable extension are removed, so an unknown-extension
# path stays visible instead of being silently dropped (issue #34517).
# Streaming and non-streaming paths share the same regex, so a tag is
# treated identically whichever path delivered the text.
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
@staticmethod
def _clean_for_display(text: str) -> str:
+32 -31
View File
@@ -6126,55 +6126,56 @@ def _prompt_model_selection(
_DIM = "\033[2m"
_RESET = "\033[0m"
# Try arrow-key menu first, fall back to number input
# Try arrow-key menu first, fall back to number input.
# Uses the shared curses radiolist (ESC/arrow-key handling that works
# across terminals, incl. those that emit raw escape sequences) instead
# of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow
# keys unreliable in the setup model picker.
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
choices = [f" {_label(mid)}" for mid in ordered]
choices.append(" Enter custom model name")
choices.append(" Skip (keep current)")
choices = [_label(mid) for mid in ordered]
choices.append("Enter custom model name")
choices.append("Skip (keep current)")
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
unavailable_footer = unavailable_message.strip()
if not unavailable_footer and _unavailable:
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
# Print the unavailable block BEFORE the menu via regular print().
# simple_term_menu pads title lines to terminal width (causes wrapping),
# so we keep the title minimal and use stdout for the static block.
# clear_screen=False means our printed output stays visible above.
# The pricing column header (and any unavailable-models block) is shown
# as a multi-line description above the list so it survives the curses
# screen clear. menu_title already embeds the aligned price header.
desc_lines: list[str] = []
if has_pricing:
# menu_title is "Select default model:\n<pad><header> /Mtok"
# Keep only the header portion for the description.
header_part = menu_title.split("\n", 1)
if len(header_part) > 1:
desc_lines.extend(header_part[1].splitlines())
if _unavailable:
print(menu_title)
print()
for mid in _unavailable:
print(f"{_DIM} {_label(mid)}{_RESET}")
print()
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
print()
effective_title = "Available free models:"
else:
effective_title = menu_title
desc_lines.append(f" {_label(mid)}")
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
menu = TerminalMenu(
idx = curses_radiolist(
"Select default model:",
choices,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title=effective_title,
selected=default_idx,
cancel_returns=-1,
description=description,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
if idx is None:
if idx < 0:
return None
print()
if idx < len(ordered):
return ordered[idx]
elif idx == len(ordered):
custom = input("Enter model name: ").strip()
try:
custom = input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return custom if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
+99
View File
@@ -670,6 +670,105 @@ def restore_quick_snapshot(
return restored > 0
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
_CRON_JOBS_REL = "cron/jobs.json"
def _count_cron_jobs(path: Path) -> Optional[int]:
"""Return the number of cron jobs stored in ``path``.
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
A legacy bare-list shape (``[...]``) is also honoured.
Returns:
The job count for any *valid, readable* JSON document, or ``None`` if
the file is missing or cannot be parsed. ``None`` means "unknown"
callers must not treat it as "zero jobs", because acting on an
unreadable file could mask a real corruption the user needs to see.
"""
if not path.is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if isinstance(data, dict):
jobs = data.get("jobs", [])
return len(jobs) if isinstance(jobs, list) else None
if isinstance(data, list):
return len(data)
return None
def restore_cron_jobs_if_emptied(
snapshot_id: str,
hermes_home: Optional[Path] = None,
) -> Optional[Dict[str, Any]]:
"""Safety net for silent cron-job loss across ``hermes update``.
Config-version migrations have been observed to leave ``cron/jobs.json``
valid-but-empty after an update, silently dropping every scheduled job
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
This compares the *current* job count against the pre-update snapshot. If
the live file now has **zero** jobs while the snapshot captured **one or
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
The check is deliberately conservative it only ever restores when there
is unambiguous evidence of loss (snapshot had jobs, live file has none),
so a user who genuinely deleted all their jobs during/after the update is
never second-guessed, and an unreadable live file (count ``None``) is left
untouched so real corruption still surfaces.
Args:
snapshot_id: The pre-update quick-snapshot id (from
:func:`create_quick_snapshot`).
hermes_home: Override for the Hermes home directory (tests).
Returns:
``None`` when no action was taken (the common, healthy path). On a
successful restore, a dict ``{"restored": True, "job_count": N,
"snapshot_id": ...}`` so the caller can warn the user.
"""
if not snapshot_id:
return None
home = hermes_home or get_hermes_home()
live_path = home / _CRON_JOBS_REL
live_count = _count_cron_jobs(live_path)
# Only act when the live file is readable AND empty. ``None`` (missing or
# unparseable) is intentionally left alone — that's a different failure
# mode the user should see rather than have papered over.
if live_count is None or live_count > 0:
return None
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
snap_count = _count_cron_jobs(snap_path)
if not snap_count: # None or 0 — nothing worth restoring
return None
try:
live_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(snap_path, live_path)
except (OSError, PermissionError) as exc:
logger.error(
"Cron jobs were emptied during update but auto-restore failed: %s", exc
)
return None
logger.warning(
"Restored %d cron job(s) from pre-update snapshot %s "
"(cron/jobs.json was emptied during migration)",
snap_count,
snapshot_id,
)
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
if not root.exists():
+38 -10
View File
@@ -12,14 +12,16 @@ import threading
import time
from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List, Optional
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
# rich and prompt_toolkit are imported lazily (inside the functions that use
# them) rather than at module level. Importing this module is on the TUI
# gateway's critical startup path purely to reach the lightweight update-check
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
# Keep the type-only reference available to checkers without the runtime cost.
if TYPE_CHECKING:
from rich.console import Console
logger = logging.getLogger(__name__)
@@ -36,6 +38,8 @@ _RST = "\033[0m"
def cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's renderer."""
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
_pt_print(_PT_ANSI(text))
@@ -221,7 +225,11 @@ def check_for_updates() -> Optional[int]:
cache_file = hermes_home / ".update_check"
embedded_rev = os.environ.get("HERMES_REVISION") or None
# Read cache — invalidate if the embedded rev has changed since last check
# Read cache — invalidate if the embedded rev OR installed version has
# changed since the last check. The version guard matters for pip installs:
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
# changes VERSION but leaves rev unchanged (both None), and without this
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
now = time.time()
try:
if cache_file.exists():
@@ -229,6 +237,7 @@ def check_for_updates() -> Optional[int]:
if (
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
and cached.get("rev") == embedded_rev
and cached.get("ver") == VERSION
):
return cached.get("behind")
except Exception:
@@ -249,7 +258,9 @@ def check_for_updates() -> Optional[int]:
behind = _check_via_local_git(repo_dir)
try:
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
cache_file.write_text(
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
)
except Exception:
pass
@@ -464,7 +475,7 @@ def _display_toolset_name(toolset_name: str) -> str:
)
def build_welcome_banner(console: Console, model: str, cwd: str,
def build_welcome_banner(console: "Console", model: str, cwd: str,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
session_id: str = None,
@@ -483,6 +494,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
context_length: Model's context window size in tokens.
"""
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
from rich.panel import Panel
from rich.table import Table
if get_toolset_for_tool is None:
from model_tools import get_toolset_for_tool
@@ -691,6 +704,21 @@ 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.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "pip":
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[/]"
)
except Exception:
pass # Never break the banner over the install-method check
right_content = "\n".join(right_lines)
layout_table.add_row(left_content, right_content)
+2 -2
View File
@@ -85,8 +85,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="<platform>", cli_only=True),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session",
args_hint="[focus topic]"),
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
args_hint="[here [N] | focus topic]"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
+77 -10
View File
@@ -285,9 +285,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
Resolution order:
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
4. .git directory presence -> 'git'
5. Fallback -> 'pip'
3. .git directory presence -> 'git'
4. Fallback -> 'pip'
Note: running inside a container is NOT treated as "docker" on its own.
The two supported install paths both self-identify via the
``.install_method`` stamp (caught by step 1), so neither relies on
container detection here:
- the curl installer (scripts/install.sh, the README/website install
command) git-clones the repo and stamps ``git``;
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
at boot via ``docker/stage2-hook.sh``.
An unsupported manual install dropped into a container (no stamp) was
wrongly classified as the published image by bare container detection,
so ``hermes update`` bailed with "doesn't apply inside the Docker
container". Without that fallback such installs fall through to the
``.git``/pip checks and behave like any off-path install. See issue #34397.
"""
stamp = get_hermes_home() / ".install_method"
try:
@@ -299,9 +312,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
from hermes_constants import is_container
if is_container():
return "docker"
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
if (project_root / ".git").is_dir():
@@ -319,6 +329,34 @@ def stamp_install_method(method: str) -> None:
pass
def is_uv_tool_install() -> bool:
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
``uv tool install hermes-agent`` places the install at
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
``uv pip install`` fails with ``No virtual environment found`` and the
update path must use ``uv tool upgrade`` instead.
Detection is intentionally restricted to properties of the running
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
NOT consult ``uv tool list``: it would also return True when
``hermes-agent`` happens to be uv-tool-installed on the machine while
the *active* Hermes is a regular pip/venv install, causing
``hermes update`` to upgrade the wrong copy. It would also block on a
subprocess call (~seconds) just to compute a recommendation string.
"""
def _has_uv_tool_marker(path: str) -> bool:
norm = os.path.normpath(path).replace(os.sep, "/").lower()
return "/uv/tools/hermes-agent/" in norm + "/"
if _has_uv_tool_marker(sys.prefix):
return True
if _has_uv_tool_marker(sys.executable or ""):
return True
return False
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if method == "nixos":
@@ -328,9 +366,10 @@ def recommended_update_command_for_method(method: str) -> str:
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
if method == "pip":
if is_uv_tool_install():
return "uv tool upgrade hermes-agent"
import shutil
uv = shutil.which("uv")
if uv:
if shutil.which("uv"):
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
return "hermes update"
@@ -1183,6 +1222,11 @@ DEFAULT_CONFIG = {
# Mirrors `hermes -c` muscle memory. Default off so existing
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
"tui_auto_resume_recent": False,
# When true (default), `hermes --tui` drops a one-time hint
# ("subagents working · /agents to watch live") the first time a turn
# starts delegating, nudging the user toward the live spawn-tree
# dashboard. Set false to suppress the hint.
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,
@@ -1202,6 +1246,13 @@ DEFAULT_CONFIG = {
# class of over-claim that otherwise forces users to run
# `git status` to verify edits landed. Set false to suppress.
"file_mutation_verifier": True,
# Turn-completion explainer. When true (default), the agent appends a
# one-line explanation to its final response whenever a turn ends
# abnormally with no usable reply — empty content after retries, a
# partial/truncated stream, a still-pending tool result, or an
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
# failure isn't silent from the UI's perspective. Set false to suppress.
"turn_completion_explainer": True,
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
# UI language for static user-facing messages (approval prompts, a
@@ -1852,7 +1903,7 @@ DEFAULT_CONFIG = {
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
# next /model or `hermes model` invocation; network failures
# silently fall back to the stale cache.
"ttl_hours": 24,
"ttl_hours": 1,
# Optional per-provider override URLs for third parties that want
# to self-host their own curation list using the same schema.
# Example:
@@ -2100,7 +2151,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 24,
"_config_version": 25,
}
# =============================================================================
@@ -4293,6 +4344,22 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
f"{', '.join(added_aux)}"
)
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
# The model picker now refreshes its curated list hourly so freshly
# published model-catalog.json deploys reach users without a day-long
# stale window. Only rewrite the OLD default (24) — never clobber a
# value the user deliberately customized.
if current_ver < 25:
config = read_raw_config()
raw_mc = config.get("model_catalog")
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
raw_mc["ttl_hours"] = 1
config["model_catalog"] = raw_mc
save_config(config)
results["config_added"].append("model_catalog.ttl_hours 24→1")
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
+41
View File
@@ -6,6 +6,7 @@ pause/resume/run/remove, status, and tick.
"""
import json
import re
import sys
from pathlib import Path
from typing import Iterable, List, Optional
@@ -15,6 +16,24 @@ sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
# Patterns that indicate a cron job targets the gateway lifecycle.
# Matches commands that restart/stop the gateway or its service manager.
# Deliberately specific — a bare "gateway ... restart" catch-all would block
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
# the API gateway logs and report restart events").
_GATEWAY_LIFECYCLE_PATTERNS = re.compile(
r"(?i)"
r"(hermes\s+gateway\s+(restart|stop|start))"
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
r"|(p?kill\s+.*hermes.*gateway)"
)
def _contains_gateway_lifecycle_command(text: str) -> bool:
"""Return True if *text* contains a gateway lifecycle command pattern."""
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
if skills is None:
@@ -166,6 +185,28 @@ def cron_status():
def cron_create(args):
# Defense: reject cron jobs that contain gateway lifecycle commands.
# Prevents agents from scheduling their own restart/stop, which creates
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
prompt = getattr(args, "prompt", None) or ""
script = getattr(args, "script", None)
combined = prompt
if script:
try:
script_text = Path(script).read_text(encoding="utf-8")
combined = f"{combined}\n{script_text}"
except (OSError, UnicodeDecodeError):
pass
if _contains_gateway_lifecycle_command(combined):
print(color(
"Blocked: cron job contains a gateway lifecycle command "
"(restart/stop/kill).\n"
"This is blocked to prevent restart loops (#30719).\n"
"Use `hermes gateway restart` from a shell outside the gateway.",
Colors.RED,
))
return 1
result = _cron_api(
action="create",
schedule=args.schedule,
+346 -268
View File
@@ -32,6 +32,196 @@ def flush_stdin() -> None:
pass
# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
# every menu's key-handling branch identical and free of raw escape-byte logic.
NAV_UP = "up"
NAV_DOWN = "down"
NAV_SELECT = "select"
NAV_TOGGLE = "toggle"
NAV_CANCEL = "cancel"
NAV_NONE = "none"
def read_menu_key(stdscr) -> str:
"""Read one keypress and normalize it to a menu action.
Decodes raw arrow-key escape sequences in addition to the translated
``curses.KEY_*`` values. Even with ``keypad(True)`` (which
``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor
keys as raw CSI/SS3 byte sequences ``getch()`` then returns ``27`` (ESC)
followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is
what made the setup wizard's provider/model pickers bail to the numbered
fallback the moment a user pressed up/down.
Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte
within a short window) is the only thing that maps to ``NAV_CANCEL`` via
the escape path; ``q`` also cancels. Unknown sequences map to
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
"""
import curses
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
if key in (curses.KEY_DOWN, ord("j")):
return NAV_DOWN
if key in (curses.KEY_ENTER, 10, 13):
return NAV_SELECT
if key == ord(" "):
return NAV_TOGGLE
if key == ord("q"):
return NAV_CANCEL
if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence.
# Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the
# bytes of an arrow key can arrive across separate reads, so a tiny
# timeout avoids misreading a split sequence as a bare ESC.
try:
stdscr.timeout(60)
nxt = stdscr.getch()
finally:
stdscr.timeout(-1) # restore blocking mode
if nxt == -1:
return NAV_CANCEL # genuine lone ESC
if nxt in (ord("["), ord("O")): # CSI / SS3 introducer
final = stdscr.getch()
if final in (ord("A"), ord("k")):
return NAV_UP
if final in (ord("B"), ord("j")):
return NAV_DOWN
# Consume the tail of any other CSI sequence (e.g. ``[3~`` Delete,
# ``[H`` Home) up to its terminator so stray bytes don't leak into
# the next input() and corrupt it.
while 0x20 <= final <= 0x3F: # CSI parameter/intermediate bytes
final = stdscr.getch()
return NAV_NONE
# ESC followed by some other byte we don't handle — swallow it.
return NAV_NONE
return NAV_NONE
# Sentinel: an on_action reducer returns this to mean "keep looping" (the
# keypress changed cursor/selection state but didn't resolve the menu).
_KEEP = object()
def _run_curses_menu(
*,
initial_cursor,
item_count,
draw_header,
draw_row,
on_action,
reserve_bottom=1,
draw_footer=None,
extra_color_pairs=False,
fallback,
cancel_value,
):
"""Shared curses single-/multi-select event loop.
Owns every piece the three public menus used to duplicate verbatim:
the non-TTY guard, ``curses.wrapper`` setup (cursor hide + color pairs),
the per-frame ``clear``/``getmaxyx``/``refresh`` cycle, scroll-offset math,
row iteration, the ``read_menu_key`` dispatch with ``NAV_UP``/``NAV_DOWN``
cursor wrap, ``flush_stdin``, and the ``KeyboardInterrupt`` / curses-
unavailable fallback. Per-menu behavior is supplied as callbacks so the
rendered output stays byte-identical to the old hand-rolled loops.
Callbacks / params:
draw_header(stdscr, max_y, max_x) -> int
Draw the title/hint/description rows. Returns the first screen row
index where the scrollable item list should start.
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
Draw one item row.
on_action(action, cursor) -> value
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
loop; return anything else to resolve the menu with that value.
(UP/DOWN cursor movement is handled by the driver itself.)
reserve_bottom: number of bottom screen rows kept clear of items
(1 = leave the final row blank, matching the old loops).
draw_footer(stdscr, max_y, max_x) -> None
Optional bottom-row painter (e.g. a status bar). Drawn after the
item rows; its row budget must be included in ``reserve_bottom``.
extra_color_pairs: also init pair 3 (dim gray) for status bars.
fallback() -> value
Called when curses errors out on a real TTY (curses unavailable).
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
"""
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
# so return the cancel value directly — matching the pre-refactor guard in
# each menu (the numbered fallback is only for curses errors on a real TTY).
if not sys.stdin.isatty():
return cancel_value
try:
import curses
result_holder = [_KEEP]
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
if extra_color_pairs:
curses.init_pair(
3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1
)
cursor = initial_cursor
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
items_start = draw_header(stdscr, max_y, max_x)
visible_rows = max_y - items_start - reserve_bottom
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
):
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
draw_row(stdscr, y, i, i == cursor, max_x)
if draw_footer is not None:
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
action = read_menu_key(stdscr)
if action == NAV_UP:
cursor = (cursor - 1) % item_count
elif action == NAV_DOWN:
cursor = (cursor + 1) % item_count
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
outcome = on_action(action, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
except KeyboardInterrupt:
return cancel_value
except Exception:
return fallback()
def curses_checklist(
title: str,
items: List[str],
@@ -54,112 +244,73 @@ def curses_checklist(
if cancel_returns is None:
cancel_returns = set(selected)
# Safety: curses and input() both hang or spin when stdin is not a
# terminal (e.g. subprocess pipe). Return defaults immediately.
if not sys.stdin.isatty():
return cancel_returns
chosen = set(selected)
try:
def _draw_header(stdscr, max_y, max_x):
import curses
chosen = set(selected)
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
cursor = 0
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
return 3
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
check = "" if i in chosen else " "
arrow = "" if is_cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Reserve bottom row for status bar when status_fn provided
footer_rows = 1 if status_fn else 0
def _draw_footer(stdscr, max_y, max_x):
import curses
try:
status_text = status_fn(chosen)
if status_text:
# Right-align on the bottom row
sx = max(0, max_x - len(status_text) - 1)
sattr = curses.A_DIM
if curses.has_colors():
sattr |= curses.color_pair(3)
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
except curses.error:
pass
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
def _on_action(action, cursor):
if action == NAV_TOGGLE:
chosen.symmetric_difference_update({cursor})
return _KEEP
if action == NAV_SELECT:
return set(chosen)
return cancel_returns # NAV_CANCEL
# Scrollable item list
visible_rows = max_y - 3 - footer_rows
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1 - footer_rows:
break
check = "" if i in chosen else " "
arrow = "" if i == cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Status bar (bottom row, right-aligned)
if status_fn:
try:
status_text = status_fn(chosen)
if status_text:
# Right-align on the bottom row
sx = max(0, max_x - len(status_text) - 1)
sattr = curses.A_DIM
if curses.has_colors():
sattr |= curses.color_pair(3)
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(items)
elif key == ord(" "):
chosen.symmetric_difference_update({cursor})
elif key in {curses.KEY_ENTER, 10, 13}:
result_holder[0] = set(chosen)
return
elif key in {27, ord("q")}:
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
return _run_curses_menu(
initial_cursor=0,
item_count=len(items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=(2 if status_fn else 1),
draw_footer=_draw_footer if status_fn else None,
extra_color_pairs=bool(status_fn),
fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn),
cancel_value=cancel_returns,
)
def curses_radiolist(
@@ -184,106 +335,68 @@ def curses_radiolist(
if cancel_returns is None:
cancel_returns = selected
if not sys.stdin.isatty():
return cancel_returns
desc_lines: list[str] = []
if description:
desc_lines = description.splitlines()
try:
def _draw_header(stdscr, max_y, max_x):
import curses
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
row = 0
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = selected
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Description lines
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
row = 0
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
# One blank row between the hint and the item list.
return row + 1
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if is_cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Description lines
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
def _on_action(action, cursor):
if action in (NAV_SELECT, NAV_TOGGLE):
return cursor
return cancel_returns # NAV_CANCEL
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
# Scrollable item list
items_start = row + 1
visible_rows = max_y - items_start - 1
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + items_start
if y >= max_y - 1:
break
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if i == cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(items)
elif key in {ord(" "), curses.KEY_ENTER, 10, 13}:
result_holder[0] = cursor
return
elif key in {27, ord("q")}:
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _radio_numbered_fallback(title, items, selected, cancel_returns)
return _run_curses_menu(
initial_cursor=selected,
item_count=len(items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
)
def _radio_numbered_fallback(
@@ -324,93 +437,58 @@ def curses_single_select(
Works inside prompt_toolkit because curses.wrapper() restores the terminal
safely, unlike simple_term_menu which conflicts with /dev/tty.
"""
if not sys.stdin.isatty():
return None
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
try:
def _draw_header(stdscr, max_y, max_x):
import curses
result_holder: list = [None]
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
def _draw(stdscr):
curses.curs_set(0)
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = min(default_index, len(all_items) - 1)
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
return 3
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
arrow = "" if is_cursor else " "
line = f" {arrow} {all_items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1:
break
arrow = "" if i == cursor else " "
line = f" {arrow} {all_items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(all_items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(all_items)
elif key in {curses.KEY_ENTER, 10, 13}:
result_holder[0] = cursor
return
elif key in {27, ord("q")}:
result_holder[0] = None
return
curses.wrapper(_draw)
flush_stdin()
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
def _on_action(action, cursor):
if action == NAV_SELECT:
# Selecting the synthetic cancel row resolves to None, mirroring
# the old post-loop ``>= cancel_idx`` guard.
return None if cursor >= cancel_idx else cursor
if action == NAV_CANCEL:
return None
return result_holder[0]
return _KEEP # NAV_TOGGLE — no-op for this menu
except KeyboardInterrupt:
return None
except Exception:
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
return _numbered_single_fallback(title, all_items, cancel_idx)
return _run_curses_menu(
initial_cursor=min(default_index, len(all_items) - 1),
item_count=len(all_items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
cancel_value=None,
)
def _numbered_single_fallback(
+58
View File
@@ -204,6 +204,60 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
issues.append(fix)
def _read_pyproject_version() -> str | None:
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
Returns None when running from an installed wheel (no pyproject.toml ships
with the package) or when the file can't be parsed. Reads only the
``[project]`` version, ignoring any version strings that appear in other
tables.
"""
pyproject = PROJECT_ROOT / "pyproject.toml"
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return None
in_project = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_project = line == "[project]"
continue
if in_project and line.startswith("version") and "=" in line:
value = line.split("=", 1)[1]
value = value.split("#", 1)[0].strip().strip("\"'")
return value or None
return None
def _check_version_consistency(issues: list[str]) -> None:
"""Verify pyproject.toml version matches hermes_cli.__version__.
A git conflict resolution (reset/merge) can revert one file without the
other, leaving ``hermes --version`` reporting a stale version while
``pyproject.toml`` is current. Detect that drift so users can re-sync.
Silent no-op for installed wheels where pyproject.toml isn't present.
"""
try:
from hermes_cli import __version__ as init_version
except Exception:
return
pyproject_version = _read_pyproject_version()
if pyproject_version is None:
# Installed wheel or unreadable pyproject — nothing to cross-check.
return
if pyproject_version == init_version:
check_ok("Version files consistent", f"({init_version})")
else:
_fail_and_issue(
"Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
issues,
)
def _check_s6_supervision(issues: list[str]) -> None:
"""Inside a container under our s6 /init, surface what s6 sees.
@@ -509,6 +563,10 @@ def run_doctor(args):
check_ok("Virtual environment active")
else:
check_warn("Not in virtual environment", "(recommended)")
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
# (a git conflict resolution can silently revert one but not the other).
_check_version_consistency(issues)
_section("Required Packages")
required_packages = [
+57 -3
View File
@@ -2161,9 +2161,37 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
return candidates
def _stable_service_working_dir() -> str:
"""Return a WorkingDirectory that will not disappear out from under systemd.
The gateway does NOT need its cwd to be the source checkout ``ExecStart``
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
when the unit is generated from a transient checkout a ``.worktrees/``
dir, or a clone that ``hermes update`` later relocates/removes the path
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
"Changing to the requested working directory failed") *before* Python
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
runs and ``Restart=always`` crash-loops forever on a dead directory.
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
it never moves, and it is guaranteed to exist whenever the gateway is
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
cannot be resolved (it always can in practice).
"""
try:
home = get_hermes_home()
if home and Path(home).is_dir():
return str(Path(home).resolve())
except Exception:
pass
return str(PROJECT_ROOT)
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
working_dir = _stable_service_working_dir()
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
@@ -2192,7 +2220,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
# (e.g. /root/) to the target user's home so the service can
# actually access them.
python_path = _remap_path_for_user(python_path, home_dir)
working_dir = _remap_path_for_user(working_dir, home_dir)
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
# rather than a remapped source-checkout path that can rot. See
# _stable_service_working_dir() for the full rationale.
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
venv_dir = _remap_path_for_user(venv_dir, home_dir)
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
@@ -2804,7 +2835,10 @@ def _launchd_domain() -> str:
def generate_launchd_plist() -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
# Stable cwd anchor — never the volatile source checkout. See
# _stable_service_working_dir() for the rationale (same rot risk applies
# to launchd's WorkingDirectory as to systemd's).
working_dir = _stable_service_working_dir()
hermes_home = str(get_hermes_home().resolve())
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
@@ -5388,6 +5422,16 @@ def _gateway_command_inner(args):
sys.exit(1)
elif subcmd == "stop":
# Defense: refuse self-targeting gateway stop from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to stop the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway stop` from a shell outside the running gateway."
)
sys.exit(1)
stop_all = getattr(args, 'all', False)
system = getattr(args, 'system', False)
@@ -5463,6 +5507,16 @@ def _gateway_command_inner(args):
print(f"✓ Stopped {get_service_name()} service")
elif subcmd == "restart":
# Defense: refuse self-targeting gateway restart from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to restart the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway restart` from a shell outside the running gateway."
)
sys.exit(1)
# Try service first, fall back to killing and restarting
service_available = False
system = getattr(args, 'system', False)
+150
View File
@@ -747,6 +747,153 @@ class GoalManager:
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
# ──────────────────────────────────────────────────────────────────────
# Kanban worker goal loop
# ──────────────────────────────────────────────────────────────────────
# Continuation prompt fed back to a kanban goal-mode worker that has not
# yet completed/blocked its task. The card's own acceptance criteria are
# the goal — the worker already has the full task body in its first turn,
# so we keep this short and point it back at the lifecycle contract.
KANBAN_GOAL_CONTINUATION_TEMPLATE = (
"[Continuing toward this kanban task — judge says it is not done yet]\n"
"Reason: {reason}\n\n"
"Take the next concrete step toward completing the task. When the work "
"is genuinely finished, call kanban_complete with a summary. If you are "
"blocked and need human input, call kanban_block with a reason. Do not "
"stop without calling one of them."
)
# Fed when the judge believes the work is done but the worker never called
# kanban_complete / kanban_block. One explicit nudge to terminate the task
# the right way before the loop gives up.
KANBAN_GOAL_FINALIZE_TEMPLATE = (
"[The work looks complete, but the task is still open]\n"
"Reason: {reason}\n\n"
"If the task is genuinely done, call kanban_complete now with a short "
"summary of what you did. If something still blocks completion, call "
"kanban_block with the reason instead."
)
def run_kanban_goal_loop(
*,
task_id: str,
goal_text: str,
run_turn,
task_status_fn,
block_fn,
max_turns: int = DEFAULT_MAX_TURNS,
first_response: str = "",
log=None,
) -> Dict[str, Any]:
"""Drive a kanban worker through a Ralph-style goal loop.
The dispatcher spawns a goal-mode worker exactly like a normal worker
(``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's
first turn has already run by the time this is called; ``first_response``
is that turn's reply. From here we:
1. Check whether the worker already terminated the task (called
``kanban_complete`` / ``kanban_block``). If so, stop nothing to do.
2. Otherwise judge the latest response against ``goal_text`` (the card's
title + body). ``continue`` feed a continuation prompt and run
another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the
task is still open one explicit "call kanban_complete" nudge.
3. When the turn budget is exhausted and the worker still hasn't
terminated the task, ``block_fn`` is invoked so the card lands in a
sticky ``blocked`` state for human review (NOT a silent exit).
This function performs NO SessionDB persistence a worker process is
ephemeral, so the turn budget lives in a local counter. It is fully
decoupled from the CLI for testability: callers inject ``run_turn``
(str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn``
(reason: str -> None).
Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where
outcome is one of ``"completed_by_worker"``, ``"blocked_budget"``,
``"blocked_by_worker"``, or ``"stopped"``.
"""
def _log(msg: str) -> None:
if log is not None:
try:
log(msg)
except Exception:
pass
max_turns = int(max_turns or DEFAULT_MAX_TURNS)
if max_turns < 1:
max_turns = DEFAULT_MAX_TURNS
last_response = first_response or ""
# The first turn already consumed one unit of budget.
turns_used = 1
nudged_to_finalize = False
while True:
# Did the worker terminate the task itself this turn?
try:
status = task_status_fn()
except Exception as exc:
_log(f"kanban goal loop: status check failed ({exc}); stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": "status check failed"}
if status == "done":
_log(f"kanban goal loop: task {task_id} completed by worker after {turns_used} turn(s)")
return {"outcome": "completed_by_worker", "turns_used": turns_used, "reason": "worker completed the task"}
if status == "blocked":
_log(f"kanban goal loop: task {task_id} blocked by worker after {turns_used} turn(s)")
return {"outcome": "blocked_by_worker", "turns_used": turns_used, "reason": "worker blocked the task"}
if status not in ("running", "ready"):
# Reclaimed / archived / unexpected — let the dispatcher own it.
_log(f"kanban goal loop: task {task_id} status={status!r}; stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"status={status}"}
# Still open — judge whether the latest response satisfies the card.
verdict, reason, _parse_failed = judge_goal(goal_text, last_response)
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
if verdict == "done":
if nudged_to_finalize:
# Already asked once to call kanban_complete and it still
# didn't — block for review rather than spin.
_log(f"kanban goal loop: task {task_id} judged done but worker won't finalize; blocking")
try:
block_fn(
f"Goal-mode worker's output looked complete but it never "
f"called kanban_complete after a finalize nudge ({reason})."
)
except Exception as exc:
_log(f"kanban goal loop: block_fn failed ({exc})")
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "judged done, never finalized"}
prompt = KANBAN_GOAL_FINALIZE_TEMPLATE.format(reason=_truncate(reason, 400))
nudged_to_finalize = True
else:
prompt = KANBAN_GOAL_CONTINUATION_TEMPLATE.format(reason=_truncate(reason, 400))
# Budget check BEFORE spending another turn.
if turns_used >= max_turns:
_log(f"kanban goal loop: task {task_id} exhausted {turns_used}/{max_turns} turns; blocking")
try:
block_fn(
f"Goal-mode worker exhausted its turn budget "
f"({turns_used}/{max_turns}) without completing the task. "
f"Last judge verdict: {_truncate(reason, 300)}"
)
except Exception as exc:
_log(f"kanban goal loop: block_fn failed ({exc})")
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "turn budget exhausted"}
# Run another turn in the same session.
try:
last_response = run_turn(prompt) or ""
except Exception as exc:
_log(f"kanban goal loop: run_turn failed ({exc}); stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"run_turn error: {type(exc).__name__}"}
turns_used += 1
__all__ = [
"GoalState",
"GoalManager",
@@ -754,9 +901,12 @@ __all__ = [
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
"JUDGE_USER_PROMPT_TEMPLATE",
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
"KANBAN_GOAL_CONTINUATION_TEMPLATE",
"KANBAN_GOAL_FINALIZE_TEMPLATE",
"DEFAULT_MAX_TURNS",
"load_goal",
"save_goal",
"clear_goal",
"judge_goal",
"run_kanban_goal_loop",
]
+15
View File
@@ -341,6 +341,19 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"two retries. Omit to use the dispatcher's "
"kanban.failure_limit config "
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
help="Run the worker in a goal loop: after each "
"turn a judge checks the response against the "
"card title/body and, if not done, the worker "
"keeps going in the same session until the "
"judge agrees it's complete (or the turn "
"budget runs out, which blocks the card for "
"review). Best for open-ended cards one shot "
"rarely finishes.")
p_create.add_argument("--goal-max-turns", type=int, default=None,
metavar="N", dest="goal_max_turns",
help="Turn budget for --goal workers (default 20). "
"Ignored without --goal.")
p_create.add_argument("--initial-status",
choices=sorted(kb.VALID_INITIAL_STATUSES),
default="running",
@@ -1343,6 +1356,8 @@ def _cmd_create(args: argparse.Namespace) -> int:
max_runtime_seconds=max_runtime,
skills=getattr(args, "skills", None) or None,
max_retries=max_retries,
goal_mode=bool(getattr(args, "goal_mode", False)),
goal_max_turns=getattr(args, "goal_max_turns", None),
initial_status=getattr(args, "initial_status", "running"),
)
task = kb.get_task(conn, task_id)
+437 -16
View File
@@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path:
return board_dir(slug) / "workspaces"
def attachments_root(board: Optional[str] = None) -> Path:
"""Return the directory under which task file attachments are stored.
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
per-board so attachments don't leak between projects. Each task gets
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
precedence) for tests and unusual deployments.
``default`` uses ``<root>/kanban/attachments/``; other boards use
``<root>/kanban/boards/<slug>/attachments/``.
Workers (which run with full file-tool access) read attached files
by the absolute path surfaced in :func:`build_worker_context`. On the
local terminal backend the default for kanban that path resolves
directly. Remote backends (Docker/Modal) need this directory mounted;
see the kanban docs.
"""
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "attachments"
return board_dir(slug) / "attachments"
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
return attachments_root(board=board) / task_id
def worker_logs_dir(board: Optional[str] = None) -> Path:
"""Return the directory under which per-task worker logs are written.
@@ -690,6 +725,19 @@ class Task:
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
max_retries: Optional[int] = None
# When True, the dispatched worker runs in a Ralph-style goal loop
# (the same engine behind the ``/goal`` slash command): after each
# turn an auxiliary judge model evaluates the worker's response
# against this card's title/body (treated as the goal). If the judge
# says "not done" and budget remains, the worker is fed a
# continuation prompt IN THE SAME SESSION and keeps working until the
# judge agrees, the goal-turn budget is exhausted (→ kanban_block),
# or the worker explicitly blocks/completes. ``False`` (default) =
# the classic single-shot worker. ``goal_max_turns`` bounds the loop.
goal_mode: bool = False
# Goal-loop turn budget for ``goal_mode`` workers. ``None`` falls
# through to the goals engine default (``goals.DEFAULT_MAX_TURNS``).
goal_max_turns: Optional[int] = None
# Originating chat/agent session id, when the task was created from
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
# tasks created from the CLI, the dashboard, or any path that doesn't
@@ -762,6 +810,12 @@ class Task:
max_retries=(
row["max_retries"] if "max_retries" in keys else None
),
goal_mode=(
bool(row["goal_mode"]) if "goal_mode" in keys and row["goal_mode"] else False
),
goal_max_turns=(
row["goal_max_turns"] if "goal_max_turns" in keys and row["goal_max_turns"] else None
),
session_id=(
row["session_id"] if "session_id" in keys else None
),
@@ -831,6 +885,20 @@ class Comment:
created_at: int
@dataclass
class Attachment:
"""In-memory view of a row from the ``task_attachments`` table."""
id: int
task_id: str
filename: str
stored_path: str
content_type: Optional[str]
size: int
uploaded_by: Optional[str]
created_at: int
@dataclass
class Event:
id: int
@@ -897,6 +965,16 @@ CREATE TABLE IF NOT EXISTS tasks (
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
-- config and then ``DEFAULT_FAILURE_LIMIT``.
max_retries INTEGER,
-- When 1, the dispatched worker runs in a Ralph-style goal loop: an
-- auxiliary judge re-evaluates the worker's response against the
-- card title/body after each turn and feeds a continuation prompt
-- back into the SAME session until the judge agrees the work is done
-- or ``goal_max_turns`` is exhausted. NULL/0 = classic single-shot
-- worker (the default).
goal_mode INTEGER NOT NULL DEFAULT 0,
-- Goal-loop turn budget for ``goal_mode`` workers. NULL = use the
-- goals-engine default.
goal_max_turns INTEGER,
-- Originating chat/agent session id when the task was created from
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
-- for tasks created from the CLI, dashboard, or any path that doesn't
@@ -957,6 +1035,23 @@ CREATE TABLE IF NOT EXISTS task_runs (
error TEXT
);
-- Files attached to a task (PDFs, images, source documents). The blob
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
-- this row carries metadata + the absolute ``stored_path`` so the
-- dashboard can list/download and ``build_worker_context`` can surface
-- the absolute path to the worker (which has full file-tool access). See
-- #35338.
CREATE TABLE IF NOT EXISTS task_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT,
size INTEGER NOT NULL DEFAULT 0,
uploaded_by TEXT,
created_at INTEGER NOT NULL
);
-- Subscription from a gateway source (platform + chat + thread) to a
-- task. The gateway's kanban-notifier watcher tails task_events and
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
@@ -981,6 +1076,7 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
"""
@@ -1517,6 +1613,20 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
if "model_override" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT")
if "goal_mode" not in cols:
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
# default) = classic single-shot worker, preserving the behaviour
# existing rows had before the column existed.
_add_column_if_missing(
conn, "tasks", "goal_mode", "goal_mode INTEGER NOT NULL DEFAULT 0"
)
if "goal_max_turns" not in cols:
# Per-task goal-loop turn budget. NULL = goals-engine default.
_add_column_if_missing(
conn, "tasks", "goal_max_turns", "goal_max_turns INTEGER"
)
if "session_id" not in cols:
# Originating agent/chat session id, populated when the task is
# created from within an agent loop that propagated
@@ -1637,6 +1747,140 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
(new, old),
)
_rebuild_drifted_tables(conn)
# Legacy DBs defined these tables with a ``TEXT PRIMARY KEY`` id (or, for
# ``kanban_notify_subs``, a nullable ``TEXT last_event_id``). The current
# schema uses ``INTEGER PRIMARY KEY AUTOINCREMENT`` / ``INTEGER NOT NULL
# DEFAULT 0``. ``CREATE TABLE IF NOT EXISTS`` skips existing tables
# regardless of schema and ``_add_column_if_missing`` only adds columns, so
# neither can fix a drifted column type — the table must be rebuilt. See
# #35096.
#
# Each entry pairs the canonical CREATE TABLE with the CREATE INDEX
# statements that DROP TABLE would otherwise take down with it (including
# ``idx_events_run``, added by the additive pass above). To guard against
# this list drifting from SCHEMA_SQL, ``test_rebuilt_schema_matches_fresh``
# asserts a rebuilt legacy DB is byte-identical to a fresh one.
_REBUILD_SPECS = {
"task_events": (
"CREATE TABLE task_events ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL,"
" payload TEXT, created_at INTEGER NOT NULL)",
(
"CREATE INDEX idx_events_task ON task_events(task_id, created_at)",
"CREATE INDEX idx_events_run ON task_events(run_id, id)",
),
),
"task_comments": (
"CREATE TABLE task_comments ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL,"
" created_at INTEGER NOT NULL)",
("CREATE INDEX idx_comments_task ON task_comments(task_id, created_at)",),
),
"task_runs": (
"CREATE TABLE task_runs ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, profile TEXT, step_key TEXT,"
" status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER,"
" worker_pid INTEGER, max_runtime_seconds INTEGER,"
" last_heartbeat_at INTEGER, started_at INTEGER NOT NULL,"
" ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT,"
" error TEXT)",
(
"CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)",
"CREATE INDEX idx_runs_status ON task_runs(status)",
),
),
"kanban_notify_subs": (
"CREATE TABLE kanban_notify_subs ("
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
" last_event_id INTEGER NOT NULL DEFAULT 0,"
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",),
),
}
def _table_has_drifted(conn: sqlite3.Connection, table: str) -> bool:
"""True when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape."""
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
if not info:
return False # table absent — nothing to rebuild
if table == "kanban_notify_subs":
lei = next((c for c in info if c["name"] == "last_event_id"), None)
return lei is not None and (lei["type"] or "").upper() != "INTEGER"
# task_events / task_comments / task_runs: id must be INTEGER and a PK.
id_col = next((c for c in info if c["name"] == "id"), None)
if id_col is None:
return False
return not ((id_col["type"] or "").upper() == "INTEGER" and id_col["pk"])
def _rebuild_drifted_tables(conn: sqlite3.Connection) -> None:
"""Rebuild any kanban table whose column types drifted from SCHEMA_SQL.
Old boards crash the gateway notifier (``int(None)`` on a NULL id in
``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
every kanban notification is silently lost (#35096). Each affected table is
rebuilt with the standard SQLite pattern CREATE new INSERT shared
columns DROP old RENAME recreating its indexes too (DROP TABLE takes
them down). The legacy TEXT ids are dropped (they aren't valid integers);
AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
so the first post-migration tick replays a task's event history once —
the safe failure mode for a feature that was already fully broken.
The whole pass runs in one transaction so an interruption can't leave a
table half-renamed, and under ``connect()``'s init locks so nothing races
it. Idempotent: a correctly-typed DB skips every table and returns without
opening a transaction.
"""
drifted = [t for t in _REBUILD_SPECS if _table_has_drifted(conn, t)]
if not drifted:
return
conn.execute("BEGIN IMMEDIATE")
try:
for table in drifted:
create_sql, index_sqls = _REBUILD_SPECS[table]
old_cols = [c["name"] for c in conn.execute(f"PRAGMA table_info({table})")]
_log.info("kanban migration: rebuilding %s to match current schema", table)
conn.execute(f"ALTER TABLE {table} RENAME TO {table}_legacy")
conn.execute(create_sql)
new_cols = {c["name"] for c in conn.execute(f"PRAGMA table_info({table})")}
if table == "kanban_notify_subs":
# Cast the legacy TEXT cursor to INTEGER; NULL / non-numeric → 0.
shared = [c for c in old_cols if c in new_cols and c != "last_event_id"]
cols_csv = ", ".join(shared)
conn.execute(
f"INSERT INTO {table} ({cols_csv}, last_event_id) "
f"SELECT {cols_csv}, COALESCE(CAST(last_event_id AS INTEGER), 0) "
f"FROM {table}_legacy"
)
else:
# Drop the legacy TEXT id; AUTOINCREMENT reassigns it.
shared = [c for c in old_cols if c in new_cols and c != "id"]
cols_csv = ", ".join(shared)
conn.execute(
f"INSERT INTO {table} ({cols_csv}) "
f"SELECT {cols_csv} FROM {table}_legacy"
)
conn.execute(f"DROP TABLE {table}_legacy")
for index_sql in index_sqls:
conn.execute(index_sql)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
raise
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
"""Read the SQLite header page_count and compare against actual file size.
@@ -1766,6 +2010,8 @@ def create_task(
max_runtime_seconds: Optional[int] = None,
skills: Optional[Iterable[str]] = None,
max_retries: Optional[int] = None,
goal_mode: bool = False,
goal_max_turns: Optional[int] = None,
initial_status: str = "running",
session_id: Optional[str] = None,
board: Optional[str] = None,
@@ -1933,8 +2179,8 @@ def create_task(
id, title, body, assignee, status, priority,
created_by, created_at, workspace_kind, workspace_path,
branch_name, tenant, idempotency_key, max_runtime_seconds,
skills, max_retries, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
skills, max_retries, goal_mode, goal_max_turns, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
@@ -1953,6 +2199,8 @@ def create_task(
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
json.dumps(skills_list) if skills_list is not None else None,
int(max_retries) if max_retries is not None else None,
1 if goal_mode else 0,
int(goal_max_turns) if goal_max_turns is not None else None,
session_id,
),
)
@@ -1972,6 +2220,7 @@ def create_task(
"tenant": tenant,
"branch_name": branch_name,
"skills": list(skills_list) if skills_list else None,
"goal_mode": bool(goal_mode) or None,
},
)
return task_id
@@ -2252,6 +2501,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
]
# ---------------------------------------------------------------------------
# Attachments
# ---------------------------------------------------------------------------
def add_attachment(
conn: sqlite3.Connection,
task_id: str,
*,
filename: str,
stored_path: str,
content_type: Optional[str] = None,
size: int = 0,
uploaded_by: Optional[str] = None,
) -> int:
"""Record a file attachment for a task. Returns the new attachment id.
The caller is responsible for writing the blob to ``stored_path``
first (under :func:`task_attachments_dir`); this only persists the
metadata row and appends an ``attached`` event.
"""
if not filename or not filename.strip():
raise ValueError("attachment filename is required")
if not stored_path or not stored_path.strip():
raise ValueError("attachment stored_path is required")
now = int(time.time())
with write_txn(conn):
if not conn.execute(
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
).fetchone():
raise ValueError(f"unknown task {task_id}")
cur = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
task_id,
filename.strip(),
stored_path,
content_type,
int(size),
uploaded_by,
now,
),
)
_append_event(
conn,
task_id,
"attached",
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
)
return int(cur.lastrowid or 0)
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
rows = conn.execute(
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
(task_id,),
).fetchall()
return [
Attachment(
id=r["id"],
task_id=r["task_id"],
filename=r["filename"],
stored_path=r["stored_path"],
content_type=r["content_type"],
size=r["size"] or 0,
uploaded_by=r["uploaded_by"],
created_at=r["created_at"],
)
for r in rows
]
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
r = conn.execute(
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
).fetchone()
if r is None:
return None
return Attachment(
id=r["id"],
task_id=r["task_id"],
filename=r["filename"],
stored_path=r["stored_path"],
content_type=r["content_type"],
size=r["size"] or 0,
uploaded_by=r["uploaded_by"],
created_at=r["created_at"],
)
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
"""Delete an attachment row and its on-disk blob. Returns the removed row.
Returns ``None`` when no row matched. The blob is removed best-effort
(a missing file is not an error); the metadata row is the source of
truth for whether an attachment "exists".
"""
with write_txn(conn):
att = get_attachment(conn, attachment_id)
if att is None:
return None
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
_append_event(
conn, att.task_id, "attachment_removed", {"filename": att.filename}
)
try:
p = Path(att.stored_path)
if p.is_file():
p.unlink()
except OSError:
pass
return att
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
rows = conn.execute(
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
@@ -2457,7 +2821,9 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
return bool(row) and row["kind"] == "blocked"
def recompute_ready(conn: sqlite3.Connection) -> int:
def recompute_ready(
conn: sqlite3.Connection, failure_limit: int = None,
) -> int:
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
Returns the number of tasks promoted. Safe to call inside or outside
@@ -2465,17 +2831,34 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
``blocked`` tasks are also considered for promotion (so a task
blocked purely by a parent dependency unblocks itself when the
parent completes), *except* when the most recent block event was a
worker-initiated ``kanban_block`` those stay blocked until an
explicit ``kanban_unblock`` (#28712). Without that guard, a
``review-required`` handoff would auto-respawn, the fresh worker
would find nothing to do, exit cleanly, get recorded as a protocol
violation, and the cycle would repeat indefinitely.
parent completes), *except* in two cases:
1. The most recent block event was a worker-initiated
``kanban_block`` those stay blocked until an explicit
``kanban_unblock`` (#28712).
2. The task's ``consecutive_failures`` has reached the effective
failure limit. This prevents infinite retry loops when a task
repeatedly exhausts its iteration budget: without this guard the
counter would reset on every recovery cycle and the circuit
breaker could never trip (#35072).
The effective failure limit resolves in the same order as the
circuit breaker in ``_record_task_failure`` so the two never
disagree about when a task is permanently blocked:
1. per-task ``max_retries`` if set
2. caller-supplied ``failure_limit`` (the dispatcher passes the
``kanban.failure_limit`` config value through ``dispatch_once``)
3. ``DEFAULT_FAILURE_LIMIT``
"""
if failure_limit is None:
failure_limit = DEFAULT_FAILURE_LIMIT
promoted = 0
with write_txn(conn):
todo_rows = conn.execute(
"SELECT id, status FROM tasks WHERE status IN ('todo', 'blocked')"
"SELECT id, status, consecutive_failures, max_retries "
"FROM tasks WHERE status IN ('todo', 'blocked')"
).fetchall()
for row in todo_rows:
task_id = row["id"]
@@ -2493,13 +2876,25 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
(task_id,),
).fetchall()
if all(p["status"] in ("done", "archived") for p in parents):
# Blocked tasks also get their failure counters reset —
# this is effectively an auto-unblock (circuit-breaker
# recovery; worker-initiated blocks are skipped above).
if cur_status == "blocked":
# Don't auto-recover tasks that have hit the
# circuit-breaker failure limit. Without this
# guard, a task that repeatedly exhausts its
# iteration budget would cycle forever:
# block → auto-recover → respawn → budget
# exhausted → block → … The counter must also
# be preserved so the breaker can accumulate
# across recovery cycles.
failures = int(row["consecutive_failures"] or 0)
task_limit = row["max_retries"]
effective_limit = (
int(task_limit) if task_limit is not None
else int(failure_limit)
)
if failures >= effective_limit:
continue
conn.execute(
"UPDATE tasks SET status = 'ready', "
"consecutive_failures = 0, last_failure_error = NULL "
"UPDATE tasks SET status = 'ready' "
"WHERE id = ? AND status = 'blocked'",
(task_id,),
)
@@ -5424,7 +5819,7 @@ def dispatch_once(
if _crash_auto_blocked:
result.auto_blocked.extend(_crash_auto_blocked)
result.timed_out = enforce_max_runtime(conn)
result.promoted = recompute_ready(conn)
result.promoted = recompute_ready(conn, failure_limit=failure_limit)
# Count tasks already running so max_spawn enforces concurrency rather
# than a per-tick spawn budget. See the docstring above for the full
@@ -6065,6 +6460,13 @@ def _default_spawn(
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
if task.claim_lock:
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
# Goal-loop mode: the worker reads these and wraps its run in the
# Ralph-style /goal judge loop (see cli.py quiet-mode path). Only set
# when enabled so non-goal tasks keep a clean env.
if task.goal_mode:
env["HERMES_KANBAN_GOAL_MODE"] = "1"
if task.goal_max_turns is not None:
env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns))
terminal_timeout = _worker_terminal_timeout_env(
task.max_runtime_seconds,
env.get("TERMINAL_TIMEOUT"),
@@ -6300,6 +6702,25 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
lines.append("")
# Attachments — files uploaded to this task (PDFs, source docs,
# images). Surface the absolute on-disk path so the worker, which has
# full file-tool access, can read them directly (read_file, terminal
# `pdftotext`, etc.). On the local terminal backend the path resolves
# as-is; remote backends need the kanban attachments dir mounted.
attachments = list_attachments(conn, task_id)
if attachments:
lines.append("## Attachments")
lines.append(
"Files attached to this task. Read them with the file/terminal "
"tools at the absolute paths below:"
)
for att in attachments:
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
size_str = f", {size_kb} KB" if size_kb else ""
ctype = f", {att.content_type}" if att.content_type else ""
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
lines.append("")
# Prior attempts — show closed runs so a retrying worker sees the
# history. Skip the currently-active run (that's this worker).
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
+431 -121
View File
@@ -65,6 +65,46 @@ import os
import sys
def _set_process_title() -> None:
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
'htop' show the app name instead of 'python3.xx'.
Purely cosmetic non-fatal on any platform.
Strategy (try in order):
1. ``setproctitle`` (opt-in dep installed via ``hermes tools`` or
``pip install setproctitle``, or bundled in a future release).
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name
changes lldb/top but not ``ps aux``).
4. No-op on Windows (the .exe name is already ``hermes.exe``).
"""
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
try:
import setproctitle # type: ignore[import-untyped]
setproctitle.setproctitle("hermes")
return
except ImportError:
pass
# Strategy 2/3: platform-specific ctypes fallback
import ctypes
import platform
try:
system = platform.system()
if system == "Linux":
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
elif system == "Darwin":
libc = ctypes.CDLL("libc.dylib", use_errno=True)
libc.pthread_setname_np(b"hermes")
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
except Exception:
pass
# Mouse-tracking residue suppression — runs BEFORE every other import on the
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
# Python launcher is still doing imports (≈100300ms in cooked + echo mode,
@@ -2354,7 +2394,12 @@ def select_provider_and_model(args=None):
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
active = "custom"
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
from hermes_cli.models import (
CANONICAL_PROVIDERS,
_PROVIDER_LABELS,
group_providers,
provider_group_for_slug,
)
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
if active and active in _custom_provider_map:
@@ -2367,8 +2412,43 @@ def select_provider_and_model(args=None):
print(f" Active provider: {active_label}")
print()
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
# Step 1: Provider selection.
#
# Canonical providers are folded into top-level groups (display only — see
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
# resolves back to a concrete slug, so the dispatch chain below is
# unchanged. Custom providers and the trailing actions stay flat.
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
# The group/slug that should be pre-selected: the active provider's group
# if it's grouped, otherwise the active slug itself.
active_group = provider_group_for_slug(active) if active else ""
# ordered entries: (key, label, members)
# members == [] → leaf row, key is a provider slug / action
# members != [] → group row, key is "group:<gid>"
ordered: list[tuple[str, str, list[str]]] = []
default_idx = 0
for row in grouped_rows:
if row["kind"] == "group":
gid = row["group_id"]
label = f"{row['label']}"
key = f"group:{gid}"
is_active = bool(active_group) and gid == active_group
members = row["members"]
else:
slug = row["slug"]
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
key = slug
is_active = bool(active) and slug == active
members = []
if is_active:
ordered.append((key, f"{label} ← currently active", members))
default_idx = len(ordered) - 1
else:
ordered.append((key, label, members))
for key, provider_info in _custom_provider_map.items():
name = provider_info["name"]
@@ -2376,36 +2456,49 @@ def select_provider_and_model(args=None):
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
saved_model = provider_info.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
# Build the menu
ordered = []
default_idx = 0
for key, label in all_providers:
label = f"{name} ({short_url}){model_hint}"
if active and key == active:
ordered.append((key, f"{label} ← currently active"))
ordered.append((key, f"{label} ← currently active", []))
default_idx = len(ordered) - 1
else:
ordered.append((key, label))
ordered.append((key, label, []))
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
config.get("custom_providers")
)
if _has_saved_custom_list:
ordered.append(("remove-custom", "Remove a saved custom provider"))
ordered.append(("aux-config", "Configure auxiliary models..."))
ordered.append(("cancel", "Leave unchanged"))
ordered.append(("remove-custom", "Remove a saved custom provider", []))
ordered.append(("aux-config", "Configure auxiliary models...", []))
ordered.append(("cancel", "Leave unchanged", []))
provider_idx = _prompt_provider_choice(
[label for _, label in ordered],
[label for _, label, _ in ordered],
default=default_idx,
)
if provider_idx is None or ordered[provider_idx][0] == "cancel":
print("No change.")
return
selected_provider = ordered[provider_idx][0]
selected_key = ordered[provider_idx][0]
selected_members = ordered[provider_idx][2]
# Group row → drill into a member sub-picker. Default to the active member
# if the active provider lives in this group.
if selected_members:
member_default = 0
if active in selected_members:
member_default = selected_members.index(active)
member_labels = [
canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members
]
member_idx = _prompt_provider_choice(member_labels, default=member_default)
if member_idx is None:
print("No change.")
return
selected_provider = selected_members[member_idx]
else:
selected_provider = selected_key
if selected_provider == "aux-config":
_aux_config_menu()
@@ -4362,23 +4455,17 @@ def _remove_custom_provider(config):
choices.append("Cancel")
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
menu = TerminalMenu(
[f" {c}" for c in choices],
cursor_index=0,
menu_cursor="-> ",
menu_cursor_style=("fg_red", "bold"),
menu_highlight_style=("fg_red",),
cycle_cursor=True,
clear_screen=False,
title="Select provider to remove:",
idx = curses_radiolist(
"Select provider to remove:",
list(choices),
selected=0,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
print()
if idx < 0:
idx = None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
for i, c in enumerate(choices, 1):
print(f" {i}. {c}")
@@ -4445,27 +4532,19 @@ def _model_flow_named_custom(config, provider_info):
print(f"Found {len(models)} model(s):\n")
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
menu_items = [
f" {m} (current)" if m == saved_model else f" {m}" for m in models
] + [" Cancel"]
menu = TerminalMenu(
f"{m} (current)" if m == saved_model else m for m in models
] + ["Cancel"]
idx = curses_radiolist(
f"Select model from {name}:",
menu_items,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title=f"Select model from {name}:",
selected=default_idx,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
print()
if idx is None or idx >= len(models):
if idx < 0 or idx >= len(models):
print("Cancelled.")
return
model_name = models[idx]
@@ -4642,26 +4721,18 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
default_idx = 0
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
choices = [f" {_label(effort)}" for effort in ordered]
choices.append(f" {disable_label}")
choices.append(f" {skip_label}")
menu = TerminalMenu(
choices = [_label(effort) for effort in ordered]
choices.append(disable_label)
choices.append(skip_label)
idx = curses_radiolist(
"Select reasoning effort:",
choices,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title="Select reasoning effort:",
selected=default_idx,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
if idx is None:
if idx < 0:
return None
print()
if idx < len(ordered):
@@ -7840,39 +7911,6 @@ def _detect_concurrent_hermes_instances(
except Exception:
return []
# Build a set of PIDs to exclude: the Python process itself plus its
# entire parent chain. On Windows the setuptools-generated hermes.exe
# launcher is a separate native process that spawns python.exe (the
# interpreter that runs our code). os.getpid() returns the Python PID,
# but the launcher (which holds the file lock) is the parent. Without
# walking the parent chain, every ``hermes update`` reports its own
# launcher as a concurrent instance — a false positive.
if exclude_pid is not None:
exclude_pids: set[int] = {exclude_pid}
else:
exclude_pids = {os.getpid()}
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
# AccessDenied) we stop walking and use whatever we've collected so far.
# Broader Exception catch on the outer block guards against partially-
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
# NoSuchProcess) — the surrounding update flow documents this helper as
# "never raises".
try:
current = psutil.Process(next(iter(exclude_pids)))
while True:
try:
parent = current.parent()
except Exception:
break
if parent is None or parent.pid <= 0:
break
if parent.pid in exclude_pids:
break # loop detected
exclude_pids.add(parent.pid)
current = parent
except Exception:
pass
# Resolve every shim path to its canonical form once for cheap comparison.
shim_paths: set[str] = set()
for shim in _hermes_exe_shims(scripts_dir):
@@ -7883,6 +7921,56 @@ def _detect_concurrent_hermes_instances(
if not shim_paths:
return []
# Build a set of PIDs to exclude: the Python process itself plus every
# ancestor whose executable is one of our shims. On Windows the
# setuptools-generated hermes.exe launcher is a separate native process
# that spawns python.exe (the interpreter that runs our code).
# os.getpid() returns the Python PID, but the launcher (which holds the
# file lock) is the parent. Without excluding it, every ``hermes update``
# reports its own launcher as a concurrent instance — a false positive
# (issues #29341, #34795).
#
# Two robustness points learned from the field:
# 1. Use ``proc.parents()`` — it returns the WHOLE ancestor list in one
# call. The earlier per-hop ``current.parent()`` loop bailed on the
# first psutil error (AccessDenied/NoSuchProcess is common on Windows
# across session/elevation boundaries), leaving the launcher shim in
# the candidate set and re-triggering the false positive.
# 2. Only exclude ancestors whose exe is itself a shim. A genuine second
# hermes.exe sitting *under* a non-Hermes parent (e.g. a Hermes
# Desktop backend child) must still be flagged, so we don't blanket-
# exclude unrelated ancestors like the shell or terminal.
# Broad ``except Exception`` guards against partially-stubbed psutil in
# unit tests; this helper is documented as "never raises".
if exclude_pid is not None:
exclude_pids: set[int] = {int(exclude_pid)}
else:
exclude_pids = {os.getpid()}
try:
seed = next(iter(exclude_pids))
try:
ancestors = psutil.Process(seed).parents()
except Exception:
ancestors = []
for ancestor in ancestors:
try:
anc_exe = ancestor.exe()
except Exception:
continue
if not anc_exe:
continue
try:
anc_norm = str(Path(anc_exe).resolve()).lower()
except (OSError, ValueError):
anc_norm = str(anc_exe).lower()
if anc_norm in shim_paths:
try:
exclude_pids.add(int(ancestor.pid))
except Exception:
continue
except Exception:
pass
matches: list[tuple[int, str]] = []
try:
proc_iter = psutil.process_iter(["pid", "exe", "name"])
@@ -7923,6 +8011,13 @@ def _format_concurrent_instances_message(
lines.append("")
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
lines.append("")
if matches:
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
lines.append(" If you've already closed everything and these PIDs are")
lines.append(" stale, terminate them directly, then retry the update:")
lines.append(f" taskkill {pid_args} /F")
lines.append("")
lines.append(" Override with `hermes update --force` if you've already")
lines.append(" confirmed those processes will not write to the venv.")
return "\n".join(lines)
@@ -8878,18 +8973,51 @@ def cmd_update(args):
def _cmd_update_pip(args):
"""Update Hermes via pip (for PyPI installs)."""
from hermes_cli import __version__
from hermes_cli.config import is_uv_tool_install
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
uv = shutil.which("uv")
if uv:
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
pipx = shutil.which("pipx") if pipx_managed else None
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
# exported (uv refuses to install without it when the launcher shim
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
# set it for them.
export_virtualenv = False
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
cmd = [pipx, "upgrade", "hermes-agent"]
elif uv:
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
if in_venv:
# Launcher shim runs the venv interpreter but doesn't export
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
export_virtualenv = True
else:
# Outside any venv, ``--system`` lets uv target the active
# interpreter, matching pip's default behaviour.
cmd.insert(3, "--system")
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
print(f"→ Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
run_kwargs = {}
if export_virtualenv:
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
result = subprocess.run(cmd, **run_kwargs)
if result.returncode != 0:
print("✗ Update failed")
sys.exit(1)
@@ -9125,12 +9253,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
pre_update_snapshot_id = None
try:
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(label="pre-update", keep=1)
if snap_id:
print(f" ✓ Pre-update snapshot: {snap_id}")
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
@@ -9406,16 +9535,61 @@ def _cmd_update_impl(args, gateway_mode: bool):
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
needs_migration = missing_env or missing_config or current_ver < latest_ver
has_new_options = bool(missing_env or missing_config)
version_bump_only = (
not has_new_options and current_ver < latest_ver
)
needs_migration = has_new_options or current_ver < latest_ver
if needs_migration:
if version_bump_only:
# Nothing for the user to fill in — only the config format version
# changed (new defaults already merge in transparently). Asking
# "configure new options now?" here is misleading: saying yes just
# bumps the version and looks like a no-op (issue: ScottFive /
# Tt2021). Apply it silently and say what actually happened.
print()
print(
f" Updating config format (v{current_ver} → v{latest_ver})…"
)
try:
migrate_config(interactive=False, quiet=True)
print(" ✓ Config format updated (no new settings to configure)")
except Exception as _mig_err:
print(f" ⚠️ Config format update failed: {_mig_err}")
print(" Run 'hermes config migrate' to retry.")
elif needs_migration:
print()
# Show WHAT changed, not just a count, so the user can make an
# informed yes/no decision (previously the prompt named nothing).
def _print_items(items, label, key, fallback_key=None):
if not items:
return
print(f" {label}:")
shown = items[:8]
for it in shown:
if isinstance(it, dict):
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
desc = (it.get("description") or "").strip()
else:
# Defensive: some callers/mocks pass bare name strings.
name = str(it)
desc = ""
if desc:
print(f"{name}{desc}")
else:
print(f"{name}")
extra = len(items) - len(shown)
if extra > 0:
print(f" … and {extra} more")
if missing_env:
print(
f" ⚠️ {len(missing_env)} new required setting(s) need configuration"
)
_print_items(missing_env, "New settings", "name")
if missing_config:
print(f" {len(missing_config)} new config option(s) available")
_print_items(missing_config, "New options", "key")
print()
if assume_yes:
@@ -9467,6 +9641,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
else:
print(" ✓ Configuration is up to date")
# Safety net: config-version migrations have been observed to leave
# cron/jobs.json valid-but-empty, silently dropping every scheduled
# job (issue #34600). If the live file is now empty while the
# pre-update snapshot held jobs, restore it and warn loudly.
try:
from hermes_cli.backup import restore_cron_jobs_if_emptied
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
if cron_restore:
print()
print(
" ⚠️ cron/jobs.json was emptied during this update — "
f"restored {cron_restore['job_count']} job(s) from "
f"pre-update snapshot {cron_restore['snapshot_id']}."
)
except Exception as exc:
# Never let the cron safety net break an otherwise-good update.
logger.debug("Cron jobs auto-restore check failed: %s", exc)
print()
print("✓ Update complete!")
@@ -10562,11 +10755,10 @@ def cmd_profile(args):
if collision:
print(f"Error: {collision}")
sys.exit(1)
wrapper_path = create_wrapper_script(alias_name)
wrapper_path = create_wrapper_script(
alias_name, target=name if custom_name else None
)
if wrapper_path:
# If custom name, write the profile name into the wrapper
if custom_name:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
print(f"✓ Alias created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"{_get_wrapper_dir()} is not in your PATH.")
@@ -10949,6 +11141,13 @@ def cmd_completion(args, parser=None):
print(generate_bash(parser))
def cmd_prompt_size(args):
"""Show a byte/char breakdown of the system prompt + tool schemas."""
from hermes_cli.prompt_size import cmd_prompt_size as _impl
_impl(args)
def cmd_logs(args):
"""View and filter Hermes log files."""
from hermes_cli.logs import tail_log, list_logs
@@ -10985,6 +11184,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
"prompt-size",
"send", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat", "secrets", "security",
@@ -11085,6 +11285,26 @@ _AGENT_SUBCOMMANDS = {
}
def _is_tui_chat_launch(args) -> bool:
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
def _command_has_dedicated_mcp_startup(args) -> bool:
if args.command == "acp":
return True
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
return True
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
return True
return False
def _should_background_mcp_startup(args) -> bool:
if _is_tui_chat_launch(args):
return False
return args.command in {None, "chat", "rl"}
def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
@@ -11104,19 +11324,42 @@ def _prepare_agent_startup(args) -> None:
"plugin discovery failed at CLI startup",
exc_info=True,
)
try:
# MCP tool discovery — no event loop running in CLI/TUI startup,
# so inline is safe. Moved here from model_tools.py module scope
# to avoid freezing the gateway's event loop on its first message
# via the same lazy import path (#16856).
from tools.mcp_tool import discover_mcp_tools
_run_inline_mcp_discovery = True
if _is_tui_chat_launch(args):
# The TUI launcher hands off to a dedicated startup path that already
# backgrounds MCP discovery with a bounded join before the first tool
# snapshot.
_run_inline_mcp_discovery = False
elif _command_has_dedicated_mcp_startup(args):
# These entrypoints already do their own MCP startup later on the real
# runtime path (gateway executor, ACP launcher, cron job runner).
_run_inline_mcp_discovery = False
elif _should_background_mcp_startup(args):
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
start_background_mcp_discovery(
logger=logger,
thread_name="cli-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at CLI startup",
exc_info=True,
)
_run_inline_mcp_discovery = False
if _run_inline_mcp_discovery:
try:
# MCP tool discovery remains synchronous for entrypoints that do
# not own a later bounded/executor startup path.
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config
@@ -11257,6 +11500,10 @@ def _try_termux_fast_tui_launch() -> bool:
def main():
"""Main entry point for hermes CLI."""
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
# in ps/top/htop. Non-fatal — just a nicer UX.
_set_process_title()
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
try:
from hermes_cli.stdio import configure_windows_stdio
@@ -13010,9 +13257,15 @@ Examples:
),
)
memory_sub = memory_parser.add_subparsers(dest="memory_command")
memory_sub.add_parser(
_setup_parser = memory_sub.add_parser(
"setup", help="Interactive provider selection and configuration"
)
_setup_parser.add_argument(
"provider",
nargs="?",
default=None,
help="Provider to configure directly (e.g. honcho), skipping the picker",
)
memory_sub.add_parser("status", help="Show current memory provider config")
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
_reset_parser = memory_sub.add_parser(
@@ -13390,6 +13643,11 @@ Examples:
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_subparsers.add_parser(
"optimize",
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
)
sessions_subparsers.add_parser("stats", help="Show session store statistics")
sessions_rename = sessions_subparsers.add_parser(
@@ -13562,6 +13820,34 @@ Examples:
relaunch(["--resume", selected_id])
return # won't reach here after execvp
elif action == "optimize":
db_path = db.db_path
before_mb = (
os.path.getsize(db_path) / (1024 * 1024)
if db_path.exists()
else 0.0
)
print("Optimizing session store (FTS merge + VACUUM)…")
try:
# vacuum() merges FTS5 segments (optimize_fts) then VACUUMs,
# and returns the number of indexes it merged.
n = db.vacuum()
except Exception as e:
print(f"Error: optimization failed: {e}")
db.close()
return
after_mb = (
os.path.getsize(db_path) / (1024 * 1024)
if db_path.exists()
else 0.0
)
saved = before_mb - after_mb
print(f"Optimized {n} FTS index(es).")
print(
f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
f"(reclaimed {saved:.1f} MB)"
)
elif action == "stats":
total = db.session_count()
msgs = db.message_count()
@@ -14175,6 +14461,30 @@ Examples:
)
logs_parser.set_defaults(func=cmd_logs)
# =========================================================================
# prompt-size command
# =========================================================================
prompt_size_parser = subparsers.add_parser(
"prompt-size",
help="Show a byte breakdown of the system prompt + tool schemas",
description=(
"Report the fixed prompt budget for a fresh session: system "
"prompt total, skills index, memory, user profile, and tool-schema "
"JSON. Runs offline (no API call)."
),
)
prompt_size_parser.add_argument(
"--platform",
default="cli",
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
)
prompt_size_parser.add_argument(
"--json",
action="store_true",
help="Emit the breakdown as JSON",
)
prompt_size_parser.set_defaults(func=cmd_prompt_size)
# =========================================================================
# Parse and execute
# =========================================================================
+46
View File
@@ -205,6 +205,22 @@ def _probe_single_server(
return tools_found
def _oauth_tokens_present(name: str) -> bool:
"""Return True if an OAuth token file exists on disk for ``name``.
Used after ``hermes mcp login`` to distinguish a genuine authentication
from a probe that succeeded only because the server allowed
initialize/tools-list without auth (so no token was ever acquired).
"""
try:
from tools.mcp_oauth import HermesTokenStorage
return HermesTokenStorage(name).has_cached_tokens()
except Exception as exc: # pragma: no cover — defensive
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
# Be permissive on unexpected errors: don't block a real success.
return True
def _unwrap_exception_group(exc: BaseException) -> Exception:
"""Extract the root-cause exception from anyio TaskGroup wrappers.
@@ -631,6 +647,36 @@ def cmd_mcp_login(args):
# Probe triggers the OAuth flow (browser redirect + callback capture).
try:
tools = _probe_single_server(name, server_config)
# A clean probe is NOT proof of authentication. Some MCP servers
# (notably Google's official Drive server) serve initialize +
# tools/list WITHOUT auth, so the probe lists tools even when the
# OAuth flow never completed — e.g. dynamic client registration
# 400'd because the provider doesn't support RFC 7591. Reporting
# "Authenticated — N tools" in that case is a false success: every
# real tool call later hangs until timeout because there's no token.
# Verify a token actually landed on disk before claiming success.
if not _oauth_tokens_present(name):
_warning(
"Server responded, but no OAuth token was obtained — "
"authentication did not complete."
)
print()
_info(
"Some providers (e.g. Google Drive, Atlassian) do not support "
"automatic client registration. For those you must create an "
"OAuth client yourself and add its credentials to config.yaml:"
)
print()
print(color(f" mcp_servers:", Colors.DIM))
print(color(f" {name}:", Colors.DIM))
print(color(f" url: {url}", Colors.DIM))
print(color(f" auth: oauth", Colors.DIM))
print(color(f" oauth:", Colors.DIM))
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
print()
_info("Then re-run `hermes mcp login " + name + "`.")
return
if tools:
_success(f"Authenticated — {len(tools)} tool(s) available")
else:
+59
View File
@@ -0,0 +1,59 @@
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
from __future__ import annotations
import threading
from typing import Optional
_mcp_discovery_lock = threading.Lock()
_mcp_discovery_started = False
_mcp_discovery_thread: Optional[threading.Thread] = None
def _has_configured_mcp_servers() -> bool:
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
try:
from hermes_cli.config import read_raw_config
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
except Exception:
# Be conservative: if config probing fails, try discovery in the
# background so startup still can't block.
return True
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
"""Spawn one shared background MCP discovery thread for this process."""
global _mcp_discovery_started, _mcp_discovery_thread
with _mcp_discovery_lock:
if _mcp_discovery_started:
return
_mcp_discovery_started = True
if not _has_configured_mcp_servers():
return
def _discover() -> None:
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("Background MCP tool discovery failed", exc_info=True)
thread = threading.Thread(
target=_discover,
name=thread_name,
daemon=True,
)
_mcp_discovery_thread = thread
thread.start()
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
"""Briefly wait for background MCP discovery before the first tool snapshot."""
thread = _mcp_discovery_thread
if thread is None or not thread.is_alive():
return
thread.join(timeout=timeout)
+5 -1
View File
@@ -452,7 +452,11 @@ def memory_command(args) -> None:
"""Route memory subcommands."""
sub = getattr(args, "memory_command", None)
if sub == "setup":
cmd_setup(args)
provider = getattr(args, "provider", None)
if provider:
cmd_setup_provider(provider)
else:
cmd_setup(args)
elif sub == "status":
cmd_status(args)
else:
+1 -1
View File
@@ -73,7 +73,7 @@ DEFAULT_CATALOG_URL = (
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
)
DEFAULT_TTL_HOURS = 24
DEFAULT_TTL_HOURS = 1
DEFAULT_FETCH_TIMEOUT = 8.0
SUPPORTED_SCHEMA_VERSION = 1
+45 -37
View File
@@ -1556,24 +1556,21 @@ def list_authenticated_providers(
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
# are grouped into a single picker row, so e.g. four Ollama entries
# pointing at ``http://localhost:11434/v1`` with per-model display names
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
# provider. Entries sharing the same endpoint, credential identity, and
# wire protocol are grouped into a single picker row, so e.g. four Ollama
# entries pointing at ``http://localhost:11434/v1`` with per-model display
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
# "Ollama" row with four models inside instead of four near-duplicates
# that differ only by suffix. Entries with distinct endpoints still
# produce separate rows.
#
# When the grouped endpoint matches ``current_base_url`` the group's
# slug becomes ``current_provider`` so that selecting a model from the
# picker flows back through the runtime provider that already holds
# valid credentials — no re-resolution needed.
# that differ only by suffix. Same-host entries with different ``key_env``
# or ``api_mode`` remain distinct providers.
if custom_providers and isinstance(custom_providers, list):
from collections import OrderedDict
# Key by (base_url, api_key) instead of slug: names frequently
# differ per model ("Ollama — X") while the endpoint stays the
# same. Slug-based grouping left them as separate rows.
# Key by endpoint + credential identity + wire protocol instead of
# slug: names frequently differ per model ("Ollama — X") while the
# endpoint stays the same. Keep same-host providers with distinct
# env-backed credentials or API protocols separate so picker selection
# cannot route through the wrong credential/mode pair.
groups: "OrderedDict[tuple, dict]" = OrderedDict()
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -1588,9 +1585,23 @@ def list_authenticated_providers(
).strip().rstrip("/")
if not raw_name or not api_url:
continue
api_key = (entry.get("api_key") or "").strip()
inline_api_key = (entry.get("api_key") or "").strip()
key_env = (entry.get("key_env") or "").strip()
api_key = inline_api_key or (
os.environ.get(key_env, "").strip() if key_env else ""
)
api_mode = str(
entry.get("api_mode")
or entry.get("transport")
or ""
).strip().lower()
credential_identity = (
inline_api_key
if inline_api_key
else (f"env:{key_env}" if key_env else "")
)
group_key = (api_url, api_key)
group_key = (api_url, credential_identity, api_mode)
if group_key not in groups:
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
# "Ollama" for the grouped row. Em dash is the convention
@@ -1603,29 +1614,16 @@ def list_authenticated_providers(
break
if not display_name:
display_name = raw_name
# If this endpoint matches the currently active one, use
# ``current_provider`` as the slug so picker-driven switches
# route through the live credential pipeline.
if (
current_base_url
and api_url == current_base_url.strip().rstrip("/")
):
# Guard against bare "custom" slug left by a prior
# failed switch — always resolve to the canonical
# custom:<name> form. (GH #17478)
slug = (
current_provider
if current_provider and current_provider != "custom"
else custom_provider_slug(display_name)
)
else:
slug = custom_provider_slug(display_name)
slug = custom_provider_slug(display_name)
groups[group_key] = {
"slug": slug,
"name": display_name,
"api_url": api_url,
"api_key": api_key,
"models": [],
}
elif api_key and not groups[group_key].get("api_key"):
groups[group_key]["api_key"] = api_key
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@@ -1647,8 +1645,16 @@ def list_authenticated_providers(
groups[group_key]["models"].append(m)
_section4_emitted_slugs: set = set()
for grp_key, grp in groups.items():
api_url, api_key = grp_key
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
_current_base_url_group_count = sum(
1
for _grp in groups.values()
if _current_base_url_norm
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
)
for grp in groups.values():
api_url = grp["api_url"]
api_key = grp.get("api_key", "")
slug = grp["slug"]
# If the slug is already claimed by a built-in / overlay /
# user-provider row (sections 1-3), skip this custom group
@@ -1721,8 +1727,10 @@ def list_authenticated_providers(
"slug": slug,
"name": grp["name"],
"is_current": slug == current_provider or (
bool(current_base_url)
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
current_provider == "custom"
and bool(_current_base_url_norm)
and _grp_url_norm == _current_base_url_norm
and _current_base_url_group_count == 1
),
"is_user_defined": True,
"models": grp["models"],
+150 -33
View File
@@ -32,34 +32,43 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("moonshotai/kimi-k2.6", "recommended"),
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
("qwen/qwen3.7-max", ""),
("anthropic/claude-haiku-4.5", ""),
# OpenAI
("openai/gpt-5.5", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
("openai/gpt-5.4-nano", ""),
("openai/gpt-5.3-codex", ""),
("xiaomi/mimo-v2.5-pro", ""),
("tencent/hy3-preview", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
# Google
("google/gemini-3-pro-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("qwen/qwen3.6-35b-a3b", ""),
("stepfun/step-3.7-flash", ""),
("minimax/minimax-m2.7", ""),
("z-ai/glm-5.1", ""),
("x-ai/grok-4.20", ""),
("google/gemini-3.5-flash", ""),
# xAI
("x-ai/grok-4.3", ""),
("nvidia/nemotron-3-super-120b-a12b", ""),
# DeepSeek
("deepseek/deepseek-v4-pro", ""),
("deepseek/deepseek-v4-flash", ""),
# Qwen
("qwen/qwen3.7-max", ""),
("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI
("moonshotai/kimi-k2.6", "recommended"),
# MiniMax
("minimax/minimax-m2.7", ""),
# Z-AI
("z-ai/glm-5.1", ""),
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
# Tencent
("tencent/hy3-preview", ""),
# StepFun
("stepfun/step-3.7-flash", ""),
# NVIDIA
("nvidia/nemotron-3-super-120b-a12b", ""),
# OpenRouter routers
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
@@ -141,31 +150,40 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-opus-4.8",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"moonshotai/kimi-k2.6",
"qwen/qwen3.7-max",
"anthropic/claude-haiku-4.5",
# OpenAI
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.3-codex",
"xiaomi/mimo-v2.5-pro",
"tencent/hy3-preview",
# Google
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.6-35b-a3b",
"stepfun/step-3.7-flash",
"minimax/minimax-m2.7",
"z-ai/glm-5.1",
"google/gemini-3.5-flash",
# xAI
"x-ai/grok-4.3",
"nvidia/nemotron-3-super-120b-a12b",
# DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
# Qwen
"qwen/qwen3.7-max",
"qwen/qwen3.6-35b-a3b",
# MoonshotAI
"moonshotai/kimi-k2.6",
# MiniMax
"minimax/minimax-m2.7",
# Z-AI
"z-ai/glm-5.1",
# Xiaomi
"xiaomi/mimo-v2.5-pro",
# Tencent
"tencent/hy3-preview",
# StepFun
"stepfun/step-3.7-flash",
# NVIDIA
"nvidia/nemotron-3-super-120b-a12b",
],
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
# provider_model_ids fallback when /v1/models is unavailable.
@@ -936,6 +954,105 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
# ---------------------------------------------------------------------------
# Provider groups — DISPLAY ONLY
#
# Some vendors expose several Hermes provider slugs (one per endpoint /
# auth method: global API, China API, OAuth coding plan, ...). Listing every
# slug as a top-level row in the interactive `hermes model` / setup wizard /
# Telegram `/model` pickers makes that list long and noisy.
#
# These groups fold related slugs under one top-level row in INTERACTIVE
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
# every member slug remains individually addressable. Grouping is a pure
# display affordance; ``group_providers()`` is the single fold used by all
# three picker surfaces so they stay consistent.
#
# group_id -> (display_label, [member_slug, ...])
#
# Member order is the order shown inside the group submenu.
# ---------------------------------------------------------------------------
PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = {
"kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]),
"minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]),
"xai": ("xAI Grok", ["xai", "xai-oauth"]),
"google": ("Google Gemini", ["gemini", "google-gemini-cli"]),
"openai": ("OpenAI", ["openai-codex", "openai-api"]),
"opencode": ("OpenCode", ["opencode-zen", "opencode-go"]),
"copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]),
}
# Reverse index: member slug -> group_id. Built once at import.
_SLUG_TO_GROUP: dict[str, str] = {
slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members
}
def provider_group_for_slug(slug: str) -> str:
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
def group_providers(slugs):
"""Fold a flat ordered slug iterable into picker rows by provider group.
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
across surfaces.
Each returned row is a dict::
{"kind": "single", "slug": <slug>} # ungrouped, or
# 1-member group
{"kind": "group", "group_id": <gid>, "label": <label>,
"members": [<slug>, ...]} # 2+ members
Rules:
* A group row appears at the position of its FIRST present member, in
the input order. Subsequent members fold into that row (and are not
emitted again).
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
restricted to the members actually present in ``slugs``.
* A group reduced to a single present member degrades to a ``single``
row no pointless one-item submenu.
* Slugs not in any group pass through as ``single`` rows, order
preserved.
* Duplicate slugs in the input are ignored after first sight.
"""
seen: set[str] = set()
# Which present members each group has, in declaration order.
group_members: dict[str, list[str]] = {}
for gid, (_label, members) in PROVIDER_GROUPS.items():
present = [m for m in members if m in set(slugs)]
if present:
group_members[gid] = present
rows = []
emitted_groups: set[str] = set()
for slug in slugs:
s = str(slug or "").strip().lower()
if not s or s in seen:
continue
seen.add(s)
gid = _SLUG_TO_GROUP.get(s, "")
if not gid:
rows.append({"kind": "single", "slug": s})
continue
if gid in emitted_groups:
continue # already folded at the first member's position
emitted_groups.add(gid)
members = group_members.get(gid, [s])
if len(members) <= 1:
rows.append({"kind": "single", "slug": members[0]})
else:
label, _ = PROVIDER_GROUPS[gid]
rows.append(
{"kind": "group", "group_id": gid, "label": label, "members": list(members)}
)
return rows
_PROVIDER_ALIASES = {
"glm": "zai",
"z-ai": "zai",
+9 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import threading
import time
import urllib.request
from dataclasses import dataclass
@@ -15,6 +16,7 @@ NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "
_ACCOUNT_INFO_CACHE_TTL = 60
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
@dataclass(frozen=True)
@@ -302,10 +304,11 @@ def _fresh_account_info(
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
cache_key = _cache_key(access_token, portal_base_url)
if not force_fresh and _account_info_cache is not None:
cached_key, cached_at, cached_info = _account_info_cache
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
return cached_info
with _ACCOUNT_INFO_CACHE_LOCK:
if not force_fresh and _account_info_cache is not None:
cached_key, cached_at, cached_info = _account_info_cache
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
return cached_info
payload = _fetch_nous_account_info(access_token, portal_base_url)
if not payload:
@@ -327,7 +330,8 @@ def _fresh_account_info(
state=refreshed_state,
portal_base_url=portal_base_url,
)
_account_info_cache = (cache_key, time.monotonic(), info)
with _ACCOUNT_INFO_CACHE_LOCK:
_account_info_cache = (cache_key, time.monotonic(), info)
return info
except Exception as exc:
return _error_info(
+197 -4
View File
@@ -7,7 +7,11 @@ from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.config import get_env_value, load_config
from hermes_cli.nous_account import NousPortalAccountInfo, get_nous_portal_account_info
from hermes_cli.nous_account import (
NousPortalAccountInfo,
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from utils import is_truthy_value
from tools.tool_backend_helpers import (
@@ -71,12 +75,16 @@ class NousSubscriptionFeatures:
def browser(self) -> NousFeatureState:
return self.features["browser"]
@property
def video_gen(self) -> NousFeatureState:
return self.features["video_gen"]
@property
def modal(self) -> NousFeatureState:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "browser", "modal")
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
for key in ordered:
yield self.features[key]
@@ -255,6 +263,7 @@ def get_nous_subscription_features(
web_tool_enabled = _toolset_enabled(config, "web")
image_tool_enabled = _toolset_enabled(config, "image_gen")
video_tool_enabled = _toolset_enabled(config, "video_gen")
tts_tool_enabled = _toolset_enabled(config, "tts")
browser_tool_enabled = _toolset_enabled(config, "browser")
modal_tool_enabled = _toolset_enabled(config, "terminal")
@@ -289,6 +298,8 @@ def get_nous_subscription_features(
browser_use_gateway = _uses_gateway(browser_cfg)
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = _uses_gateway(image_gen_cfg)
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
video_use_gateway = _uses_gateway(video_gen_cfg)
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
@@ -296,6 +307,7 @@ def get_nous_subscription_features(
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
direct_searxng = bool(get_env_value("SEARXNG_URL"))
direct_fal = fal_key_is_configured()
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
direct_openai_tts = bool(resolve_openai_audio_api_key())
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
@@ -311,6 +323,8 @@ def get_nous_subscription_features(
direct_tavily = False
if image_use_gateway:
direct_fal = False
if video_use_gateway:
direct_fal_video = False
if tts_use_gateway:
direct_openai_tts = False
direct_elevenlabs = False
@@ -320,6 +334,8 @@ def get_nous_subscription_features(
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
# Video gen uses the same fal-queue gateway as image gen.
managed_video_available = managed_image_available
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
@@ -357,6 +373,10 @@ def get_nous_subscription_features(
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
image_available = bool(managed_image_available or direct_fal)
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
video_available = bool(managed_video_available or direct_fal_video)
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
@@ -451,6 +471,18 @@ def get_nous_subscription_features(
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
),
"video_gen": NousFeatureState(
key="video_gen",
label="Video generation",
included_by_default=False,
available=video_available,
active=video_active,
managed_by_nous=video_managed,
direct_override=video_active and not video_managed,
toolset_enabled=video_tool_enabled,
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
explicit_configured=direct_fal_video,
),
"tts": NousFeatureState(
key="tts",
label="OpenAI TTS",
@@ -559,8 +591,22 @@ def apply_nous_managed_defaults(
changed.add("browser")
if "image_gen" in selected_toolsets and not fal_key_is_configured():
image_cfg = config.get("image_gen")
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in selected_toolsets and not fal_key_is_configured():
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
@@ -571,6 +617,7 @@ def apply_nous_managed_defaults(
_GATEWAY_TOOL_LABELS = {
"web": "Web search & extract (Firecrawl)",
"image_gen": "Image generation (FAL)",
"video_gen": "Video generation (FAL)",
"tts": "Text-to-speech (OpenAI TTS)",
"browser": "Browser automation (Browser Use)",
}
@@ -578,6 +625,7 @@ _GATEWAY_TOOL_LABELS = {
def _get_gateway_direct_credentials() -> Dict[str, bool]:
"""Return a dict of tool_key -> has_direct_credentials."""
fal_direct = fal_key_is_configured()
return {
"web": bool(
get_env_value("FIRECRAWL_API_KEY")
@@ -586,7 +634,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
or get_env_value("TAVILY_API_KEY")
or get_env_value("EXA_API_KEY")
),
"image_gen": fal_key_is_configured(),
"image_gen": fal_direct,
"video_gen": fal_direct,
"tts": bool(
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
@@ -601,11 +650,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
_GATEWAY_DIRECT_LABELS = {
"web": "Firecrawl/Exa/Parallel/Tavily key",
"image_gen": "FAL key",
"video_gen": "FAL key",
"tts": "OpenAI/ElevenLabs key",
"browser": "Browser Use/Browserbase key",
}
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
def get_gateway_eligible_tools(
@@ -646,6 +696,7 @@ def get_gateway_eligible_tools(
opted_in = {
"web": _uses_gateway(config.get("web")),
"image_gen": _uses_gateway(config.get("image_gen")),
"video_gen": _uses_gateway(config.get("video_gen")),
"tts": _uses_gateway(config.get("tts")),
"browser": _uses_gateway(config.get("browser")),
}
@@ -714,6 +765,15 @@ def apply_gateway_defaults(
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in tool_keys:
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
@@ -826,3 +886,136 @@ def prompt_enable_tool_gateway(
if already_managed and not newly_switched:
print(" (all tools already using Tool Gateway)")
return changed
# ---------------------------------------------------------------------------
# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`)
# ---------------------------------------------------------------------------
def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> bool:
"""Make sure the user has paid Nous Portal access, logging in if needed.
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
login, this:
- does NOT change the inference provider (``model.provider`` is untouched),
- does NOT run model selection, and
- does NOT offer the bulk "enable for all tools" Tool Gateway prompt.
It only performs the Nous Portal device-code OAuth (when the user isn't
already logged in) and refreshes entitlement, so the caller can enable the
single tool the user picked.
Returns ``True`` when the account has paid service access after the flow,
``False`` otherwise (declined login, login failed, or no paid entitlement).
"""
# Fast path: already entitled.
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
if info is not None and info.paid_service_access is True:
return True
# If not logged in at all, run the device-code login (auth only).
if info is None or not info.logged_in:
if not _run_nous_portal_login_only(capability=capability):
return False
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
if info is not None and info.paid_service_access is True:
return True
# Logged in but no paid access — surface billing guidance, do not enable.
message = format_nous_portal_entitlement_message(info, capability=capability)
if message:
for line in message.splitlines():
print(f" {line}")
return False
def _run_nous_portal_login_only(*, capability: str) -> bool:
"""Run the Nous Portal device-code OAuth and persist credentials only.
No model selection, no provider switch, no Tool Gateway bulk prompt.
Returns ``True`` on a successful login, ``False`` if the user declined or
the flow failed.
"""
try:
from hermes_cli.auth import (
_auth_store_lock,
_load_auth_store,
_nous_device_code_login,
_read_shared_nous_state,
_save_auth_store,
_save_provider_state,
_sync_nous_pool_from_auth_store,
_try_import_shared_nous_state,
_write_shared_nous_state,
)
except Exception as exc: # pragma: no cover - defensive
print(f" Could not start Nous Portal login: {exc}")
return False
print()
print(f" {capability} requires a Nous Portal login.")
try:
proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
if proceed not in {"", "y", "yes"}:
print(" Skipped Nous Portal login.")
return False
try:
# Snapshot the active_provider so a tool-config login never silently
# switches the user's inference provider to Nous.
with _auth_store_lock():
prior_active_provider = _load_auth_store().get("active_provider")
auth_state = None
shared = _read_shared_nous_state()
if shared:
try:
do_import = input(
" Found existing Nous OAuth credentials. Import them? [Y/n]: "
).strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in {"", "y", "yes"}:
auth_state = _try_import_shared_nous_state(timeout_seconds=15.0)
if auth_state is None:
auth_state = _nous_device_code_login()
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", auth_state)
# Preserve the user's existing inference provider — this login is
# for tool entitlement only, not a provider switch.
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
_save_auth_store(auth_store)
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print(" Nous Portal login successful.")
return True
except KeyboardInterrupt:
print("\n Login cancelled.")
return False
except SystemExit:
# _nous_device_code_login raises SystemExit on subscription_required;
# it already printed billing guidance.
return False
except Exception as exc:
print(f" Nous Portal login failed: {exc}")
return False
+39 -12
View File
@@ -174,28 +174,55 @@ def run_oneshot(
# Redirect stderr AND stdout to devnull for the entire call tree.
# We'll print the final response to the real stdout at the end.
real_stdout = sys.stdout
real_stderr = sys.stderr
devnull = open(os.devnull, "w", encoding="utf-8")
response: Optional[str] = None
failure: BaseException | None = None
try:
with redirect_stdout(devnull), redirect_stderr(devnull):
response = _run_agent(
prompt,
model=model,
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
)
try:
response = _run_agent(
prompt,
model=model,
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
)
except BaseException as exc: # noqa: BLE001
# Capture anything that escapes the agent (including OSError
# from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,
# KeyboardInterrupt, SystemExit, etc.) so we can surface it on
# the real stderr instead of crashing past the redirect with a
# traceback that the caller never sees. A silent exit in a
# cron / SSH / subprocess context is the worst failure mode.
# See #30623.
failure = exc
finally:
try:
devnull.close()
except Exception:
pass
if response:
real_stdout.write(response)
if not response.endswith("\n"):
real_stdout.write("\n")
real_stdout.flush()
if failure is not None:
# Re-raise control-flow exceptions so the parent handles them as usual
# (Ctrl-C / explicit sys.exit() inside the agent).
if isinstance(failure, (KeyboardInterrupt, SystemExit)):
raise failure
real_stderr.write(f"hermes -z: agent failed: {failure}\n")
real_stderr.flush()
return 1
if not (response or "").strip():
real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
real_stderr.flush()
return 1
assert response is not None # narrowed by the empty-response guard above
real_stdout.write(response)
if not response.endswith("\n"):
real_stdout.write("\n")
real_stdout.flush()
return 0
+235
View File
@@ -0,0 +1,235 @@
"""Boundary-aware partial compression — "summarize up to here".
Inspired by Claude Code's Rewind menu "Summarize up to here" action
(v2.1.139v2.1.142, Week 20, May 2026):
https://code.claude.com/docs/en/whats-new/2026-w20
Hermes already has ``/compress`` (full-history compaction) and an
automatic token-budget tail-protection heuristic inside
``ContextCompressor``. What was missing is *user-chosen* boundary
control: "fold everything before this point into a summary, but keep
my most recent N exchanges exactly as they are." That is the value of
the Claude Code feature the user decides the compression boundary
instead of leaving it to the token-budget heuristic.
This module owns the pure, side-effect-free split logic so both the
CLI (``cli.py::_manual_compress``) and the gateway
(``gateway/run.py::_handle_compress_command``) share one
implementation. The slash-command surfaces handle compression of the
*head* via the existing ``_compress_context`` pipeline (preserving all
the session-rotation / lock / memory-notify machinery) and then
re-append the verbatim *tail* returned here.
Design notes / invariants honored:
* **Role alternation.** The compressed head ends with summary/handoff
content (assistant- or user-role, possibly a trailing todo snapshot).
The verbatim tail must begin with a ``user`` message so the rejoined
history keeps the userassistant alternation that providers validate.
:func:`split_history_for_partial_compress` snaps the tail boundary
backwards to the nearest ``user`` turn so the rejoin is always legal.
* **No silent context mutation.** This is a manual, user-invoked
action. It rotates the session exactly like ``/compress`` does (via
the caller), so the prompt-cache reset is explicit and expected, not
silent.
* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user
turn plus its following assistant/tool turns), defaulting to 2. The
split never compresses if doing so would leave nothing in the head.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
#: Default number of recent exchanges to preserve verbatim when the user
#: runs ``/compress here`` without an explicit count.
DEFAULT_KEEP_LAST = 2
#: Hard ceiling so a fat-fingered ``/compress here 9999`` doesn't turn
#: into a no-op surprise — clamp instead.
MAX_KEEP_LAST = 100
def parse_partial_compress_args(
raw_args: str,
) -> Tuple[bool, int, Optional[str]]:
"""Parse the argument string after ``/compress``.
Recognizes the boundary-aware forms:
* ``here`` partial compress, keep ``DEFAULT_KEEP_LAST``
* ``here 4`` partial compress, keep 4 exchanges
* ``--keep 4`` partial compress, keep 4 exchanges
* ``up to here`` alias for ``here`` (matches Claude Code's
menu label "Summarize up to here")
Anything else is treated as a focus topic for the existing full
``/compress <focus>`` behavior.
Returns ``(partial, keep_last, focus_topic)``:
* ``partial`` True when a boundary-aware form was requested.
* ``keep_last`` exchanges to preserve verbatim (only meaningful
when ``partial`` is True).
* ``focus_topic`` focus string for full compression, or None.
Always None when ``partial`` is True (the two modes are exclusive;
a focused partial compress is not a documented Claude Code
behavior and would muddy the UX).
"""
text = (raw_args or "").strip()
if not text:
return False, DEFAULT_KEEP_LAST, None
lowered = text.lower()
# Normalize the "up to here" alias to "here".
if lowered.startswith("up to here"):
lowered = lowered[len("up to ") :]
text = text[len("up to ") :]
tokens = lowered.split()
# Form: here [N]
if tokens and tokens[0] == "here":
keep = DEFAULT_KEEP_LAST
if len(tokens) >= 2:
keep = _coerce_keep(tokens[1])
return True, keep, None
# Form: --keep N (or --keep=N)
if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2:
return True, _coerce_keep(tokens[1]), None
if tokens and tokens[0].startswith("--keep="):
return True, _coerce_keep(tokens[0].split("=", 1)[1]), None
# Otherwise: full compression with this as the focus topic.
return False, DEFAULT_KEEP_LAST, text or None
def _coerce_keep(value: str) -> int:
"""Parse a keep-count token, clamping to [1, MAX_KEEP_LAST]."""
try:
n = int(value)
except (TypeError, ValueError):
return DEFAULT_KEEP_LAST
if n < 1:
return 1
if n > MAX_KEEP_LAST:
return MAX_KEEP_LAST
return n
def split_history_for_partial_compress(
history: List[Dict[str, Any]],
keep_last: int,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Split ``history`` into ``(head, tail)`` for partial compression.
``head`` is the earlier portion that will be summarized; ``tail`` is
the most recent ``keep_last`` exchanges, preserved verbatim.
An *exchange* is counted by ``user``-role messages: keeping N
exchanges means keeping everything from the Nth-most-recent ``user``
message onward. This guarantees the tail starts on a ``user`` turn,
so when the caller rejoins ``compressed_head + tail`` the
userassistant alternation stays valid (the compressed head's
trailing content is followed by a fresh user turn).
Returns ``(head, tail)``. If the split would leave the head empty
(not enough history to compress meaningfully), returns
``(history, [])`` signaling the caller to fall back to full
compression or report "nothing to do".
"""
if keep_last < 1:
keep_last = 1
n = len(history)
if n == 0:
return [], []
# Walk backwards collecting the indices of the most recent `keep_last`
# user-message starts. The tail begins at the earliest such index.
user_starts: List[int] = []
for idx in range(n - 1, -1, -1):
if history[idx].get("role") == "user":
user_starts.append(idx)
if len(user_starts) >= keep_last:
break
if not user_starts:
# No user turns at all (degenerate) — nothing sensible to keep
# as a "recent exchange"; treat as full compression.
return list(history), []
boundary = user_starts[-1] # earliest of the kept user starts
head = history[:boundary]
tail = history[boundary:]
# If everything is in the tail (nothing left to compress), signal the
# caller to fall back to full compression rather than producing a
# no-op that rotates the session for no benefit.
if not head:
return list(history), []
return head, tail
def rejoin_compressed_head_and_tail(
compressed_head: List[Dict[str, Any]],
tail: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Concatenate a compressed head with the verbatim tail, defending
the seam against an illegal useruser / assistantassistant adjacency.
In normal operation the compressed head ends with the head's own
protected verbatim tail (the ``ContextCompressor`` always preserves a
recent window), which terminates on an ``assistant``/``tool`` turn
so ``assistant user`` at the seam is already valid. But the head
compressor's exact output shape is not contractually guaranteed (a
plugin context engine could return something that ends on a ``user``
turn, or a degenerate single-summary message). Rather than trust the
seam, this helper inspects the boundary and, if the last head message
and the first tail message share a ``user``/``assistant`` role, folds
the tail's first message content onto the head's last message so the
rejoined list never violates provider role-alternation rules.
``tool`` messages are left alone consecutive ``tool`` entries are
the one legal repetition (parallel tool results).
"""
if not tail:
return list(compressed_head)
if not compressed_head:
return list(tail)
head = list(compressed_head)
rest = list(tail)
last = head[-1]
first = rest[0]
last_role = last.get("role")
first_role = first.get("role")
if last_role == first_role and last_role in ("user", "assistant"):
# Illegal adjacency. Merge the tail's first message text into the
# head's last message so alternation is preserved. Only string
# contents are merged inline; structured/multimodal contents fall
# back to dropping the redundant standalone (the content is
# preserved by concatenation when both are strings).
last_content = last.get("content")
first_content = first.get("content")
if isinstance(last_content, str) and isinstance(first_content, str):
merged = dict(last)
merged["content"] = f"{last_content}\n\n{first_content}"
head[-1] = merged
rest = rest[1:]
else:
# Can't safely string-merge multimodal content. Insert a
# minimal bridging turn so the seam alternates rather than
# losing data.
bridge_role = "assistant" if first_role == "user" else "user"
head.append({"role": bridge_role, "content": ""})
return head + rest
+65 -30
View File
@@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
# Check existing commands in PATH
wrapper_dir = _get_wrapper_dir()
is_windows = sys.platform == "win32"
try:
result = subprocess.run(
["which", canon], capture_output=True, text=True, timeout=5,
["where" if is_windows else "which", canon],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
existing_path = result.stdout.strip()
existing_path = result.stdout.strip().splitlines()[0]
# Allow overwriting our own wrappers
if existing_path == str(wrapper_dir / canon):
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
if existing_path == str(expected):
try:
content = (wrapper_dir / canon).read_text()
content = expected.read_text()
if "hermes -p" in content:
return None # it's our wrapper, safe to overwrite
except Exception:
@@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
def create_wrapper_script(name: str) -> Optional[Path]:
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
"""Create a shell wrapper script at ~/.local/bin/<name>.
The wrapper file is named after ``name`` (the alias). The profile it
activates is ``target`` if given, otherwise ``name`` this lets a custom
alias name point at a differently-named profile without a post-hoc rewrite.
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
Returns the path to the created wrapper, or None if creation failed.
"""
canon = normalize_profile_name(name)
profile = normalize_profile_name(target) if target else canon
wrapper_dir = _get_wrapper_dir()
try:
wrapper_dir.mkdir(parents=True, exist_ok=True)
@@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:
print(f"⚠ Could not create {wrapper_dir}: {e}")
return None
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
is_windows = sys.platform == "win32"
if is_windows:
wrapper_path = wrapper_dir / f"{canon}.bat"
try:
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
else:
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
wrapper_dir = _get_wrapper_dir()
canon = normalize_profile_name(name)
is_windows = sys.platform == "win32"
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
return False
@@ -1443,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
"""Rename Honcho host blocks for a renamed profile without changing peers."""
old_host = f"hermes.{old_name}"
new_host = f"hermes.{new_name}"
old_host = f"hermes_{old_name}"
legacy_old_host = f"hermes.{old_name}"
new_host = f"hermes_{new_name}"
candidates = [
new_dir / "honcho.json",
@@ -1468,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
continue
hosts = raw.get("hosts")
if not isinstance(hosts, dict) or old_host not in hosts:
if not isinstance(hosts, dict):
continue
source_host = old_host if old_host in hosts else legacy_old_host
if source_host not in hosts:
continue
if new_host in hosts:
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
continue
block = hosts[old_host]
block = hosts[source_host]
if isinstance(block, dict) and "aiPeer" not in block:
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
if source_host.startswith("hermes_"):
bare = source_host.split("_", 1)[1]
else:
bare = source_host.split(".", 1)[1] if "." in source_host else source_host
block["aiPeer"] = bare
hosts[new_host] = hosts.pop(old_host)
hosts[new_host] = hosts.pop(source_host)
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -1491,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
pass
continue
print(f"✓ Honcho host updated: {old_host}{new_host}")
print(f"✓ Honcho host updated: {source_host}{new_host}")
def rename_profile(old_name: str, new_name: str) -> Path:
+153
View File
@@ -0,0 +1,153 @@
"""Prompt-size diagnostic: ``hermes prompt-size``.
Reports a byte/char breakdown of the system prompt the agent would build for
a fresh session system prompt total, the ``<available_skills>`` index,
memory + user profile, and tool-schema JSON. Lets users see where their fixed
prompt budget goes (issue #34667) without parsing a saved session JSON by hand.
The diagnostic builds a real inspection agent (so the numbers match what
actually ships on the wire) but never makes a network call: it passes dummy
credentials so ``AIAgent.__init__`` takes the direct-construction path, then
calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Tuple
# The skills index is wrapped in this tag pair inside the stable tier.
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
def _bytes(s: str) -> int:
return len(s.encode("utf-8"))
def _build_inspection_agent(platform: str) -> Any:
"""Construct an offline AIAgent for prompt inspection.
Dummy ``api_key`` + ``base_url`` force the direct-construction path in
``run_agent.py`` (no provider auto-detection, no network). Toolsets and
platform come from the caller so the breakdown matches a real session.
"""
from run_agent import AIAgent
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
model = model_cfg.get("default") or model_cfg.get("model") or ""
return AIAgent(
model=model,
api_key="inspect-only",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
save_trajectories=False,
platform=platform,
)
def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]:
"""Return a dict of prompt-size measurements for a fresh session.
Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``,
``user_profile``, ``tools`` (count + json bytes), and ``sections`` (a list
of (label, chars, bytes) for the three prompt tiers).
"""
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
agent = _build_inspection_agent(platform)
parts = build_system_prompt_parts(agent)
full = build_system_prompt(agent)
stable = parts.get("stable", "")
context = parts.get("context", "")
volatile = parts.get("volatile", "")
# Skills index — the <available_skills> block (the largest single block
# when many skills are installed). Measured inside the stable tier.
skills_match = _SKILLS_BLOCK_RE.search(stable)
skills_index = skills_match.group(0) if skills_match else ""
# Memory + user profile live in the volatile tier. We re-derive their
# blocks directly from the memory store so the numbers are attributable
# even though they're joined into ``volatile``.
memory_block = ""
user_block = ""
store = getattr(agent, "_memory_store", None)
if store is not None:
try:
if getattr(agent, "_memory_enabled", True):
memory_block = store.format_for_system_prompt("memory") or ""
if getattr(agent, "_user_profile_enabled", True):
user_block = store.format_for_system_prompt("user") or ""
except Exception:
pass
# Tool-schema JSON — the other half of the fixed per-call payload.
tools = getattr(agent, "tools", None) or []
tools_json = json.dumps(tools, ensure_ascii=False)
sections: List[Tuple[str, int, int]] = [
("stable (identity/guidance/skills)", len(stable), _bytes(stable)),
("context (AGENTS.md/cwd files)", len(context), _bytes(context)),
("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)),
]
return {
"platform": platform,
"model": getattr(agent, "model", "") or "",
"system_prompt": {"chars": len(full), "bytes": _bytes(full)},
"skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)},
"memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)},
"user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)},
"tools": {"count": len(tools), "json_bytes": _bytes(tools_json)},
"sections": sections,
}
def _fmt_kb(n: int) -> str:
return f"{n / 1024:.1f} KB"
def render_breakdown(data: Dict[str, Any]) -> str:
"""Render the breakdown as plain text suitable for a terminal."""
lines: List[str] = []
sp = data["system_prompt"]
lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})")
lines.append("")
lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)")
lines.append("")
lines.append(" Major blocks:")
si = data["skills_index"]
mem = data["memory"]
up = data["user_profile"]
lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})")
lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})")
lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})")
lines.append("")
lines.append(" Prompt tiers:")
for label, chars, byts in data["sections"]:
lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})")
lines.append("")
tools = data["tools"]
lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)")
return "\n".join(lines)
def cmd_prompt_size(args: Any) -> None:
"""Entry point for ``hermes prompt-size``."""
platform = getattr(args, "platform", "cli") or "cli"
as_json = getattr(args, "json", False)
try:
data = compute_prompt_breakdown(platform)
except Exception as e:
print(f"Could not compute prompt-size breakdown: {e}")
return
if as_json:
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(render_breakdown(data))
+3 -1
View File
@@ -104,7 +104,9 @@ ADVISORIES: tuple[Advisory, ...] = (
"them to a hardcoded webhook. If you ran any Python process that "
"imported mistralai 2.4.6 — including hermes when configured "
"with provider=mistral for TTS or STT — assume those credentials "
"are exposed."
"are exposed. PyPI has since removed 2.4.6 and the project ships "
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
"the compromised 2.4.6 is still installed."
),
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
compromised=(
+89 -244
View File
@@ -305,7 +305,7 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
appended at the end the user toggles items with Space and confirms
with Enter on "Continue →".
Falls back to a numbered toggle interface when simple_term_menu is
Falls back to a numbered toggle interface when curses is
unavailable.
Returns:
@@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home):
# Video generation — opt-in via `hermes tools` → Video Generation.
# Only show the row when a plugin reports available so we don't badger
# users who don't care about video gen with a "missing" status line.
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
if subscription_features.video_gen.managed_by_nous:
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
else:
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
# TTS — show configured provider
tts_provider = cfg_get(config, "tts", "provider", default="edge")
@@ -734,175 +737,15 @@ def setup_model_provider(config: dict, *, quick: bool = False):
if isinstance(_m, dict):
selected_provider = _m.get("provider")
# ── Same-provider fallback & rotation setup (full setup only) ──
if not quick and _supports_same_provider_pool_setup(selected_provider):
try:
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_add_command
pool = load_pool(selected_provider)
entries = pool.entries()
entry_count = len(entries)
manual_count = sum(1 for entry in entries if str(getattr(entry, "source", "")).startswith("manual"))
auto_count = entry_count - manual_count
print()
print_header("Same-Provider Fallback & Rotation")
print_info(
"Hermes can keep multiple credentials for one provider and rotate between"
)
print_info(
"them when a credential is exhausted or rate-limited. This preserves"
)
print_info(
"your primary provider while reducing interruptions from quota issues."
)
print()
if auto_count > 0:
print_info(
f"Current pooled credentials for {selected_provider}: {entry_count} "
f"({manual_count} manual, {auto_count} auto-detected from env/shared auth)"
)
else:
print_info(f"Current pooled credentials for {selected_provider}: {entry_count}")
while prompt_yes_no("Add another credential for same-provider fallback?", False):
auth_add_command(
SimpleNamespace(
provider=selected_provider,
auth_type="",
label=None,
api_key=None,
portal_url=None,
inference_url=None,
client_id=None,
scope=None,
no_browser=False,
timeout=15.0,
insecure=False,
ca_bundle=None,
)
)
pool = load_pool(selected_provider)
entry_count = len(pool.entries())
print_info(f"Provider pool now has {entry_count} credential(s).")
if entry_count > 1:
strategy_labels = [
"Fill-first / sticky — keep using the first healthy credential until it is exhausted",
"Round robin — rotate to the next healthy credential after each selection",
"Random — pick a random healthy credential each time",
]
current_strategy = _get_credential_pool_strategies(config).get(selected_provider, "fill_first")
default_strategy_idx = {
"fill_first": 0,
"round_robin": 1,
"random": 2,
}.get(current_strategy, 0)
strategy_idx = prompt_choice(
"Select same-provider rotation strategy:",
strategy_labels,
default_strategy_idx,
)
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
_set_credential_pool_strategy(config, selected_provider, strategy_value)
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
except Exception as exc:
logger.debug("Could not configure same-provider fallback in setup: %s", exc)
# ── Vision & Image Analysis Setup (full setup only) ──
if quick:
_vision_needs_setup = False
else:
try:
from agent.auxiliary_client import get_available_vision_backends
_vision_backends = set(get_available_vision_backends())
except Exception:
_vision_backends = set()
_vision_needs_setup = not bool(_vision_backends)
if selected_provider in _vision_backends:
_vision_needs_setup = False
if _vision_needs_setup:
_prov_names = {
"nous-api": "Nous Portal API key",
"copilot": "GitHub Copilot",
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"stepfun": "StepFun Step Plan",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
print()
print_header("Vision & Image Analysis (optional)")
print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
print_info("doesn't currently provide one Hermes can auto-use for vision,")
print_info("so choose a backend now or skip and configure later.")
print()
_vision_choices = [
"OpenRouter — uses Gemini (free tier at openrouter.ai/keys)",
"OpenAI-compatible endpoint — base URL, API key, and vision model",
"Skip for now",
]
_vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)
if _vision_idx == 0: # OpenRouter
_or_key = prompt(" OpenRouter API key", password=True).strip()
if _or_key:
save_env_value("OPENROUTER_API_KEY", _or_key)
print_success("OpenRouter key saved — vision will use Gemini")
else:
print_info("Skipped — vision won't be available")
elif _vision_idx == 1: # OpenAI-compatible endpoint
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
_api_key_label = " API key"
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
if _is_native_openai:
_api_key_label = " OpenAI API key"
_oai_key = prompt(_api_key_label, password=True).strip()
if _oai_key:
save_env_value("OPENAI_API_KEY", _oai_key)
# Save vision base URL to config (not .env — only secrets go there)
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
_vaux["base_url"] = _base_url
if _is_native_openai:
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
_selected_vision_model = (
_oai_vision_models[_vm_idx]
if _vm_idx < len(_oai_vision_models)
else "gpt-4o-mini"
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
)
else:
print_info("Skipped — vision won't be available")
else:
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
# Credential rotation, vision-backend selection, and TTS provider are no
# longer prompted here. They have safe defaults (rotation off, vision
# auto-detected from the main provider, TTS = Edge) and are configurable
# on demand via `hermes auth add`, `hermes setup` vision, and
# `hermes setup tts`. This keeps both quick and full setup thin.
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
if not quick and selected_provider != "nous":
_setup_tts_provider(config)
# =============================================================================
# Section 1b: TTS Provider Configuration
@@ -1338,29 +1181,9 @@ def setup_terminal_backend(config: dict):
if selected_backend == "local":
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# Gateway/cron working directory
print()
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
# Sudo support
print()
existing_sudo = get_env_value("SUDO_PASSWORD")
if existing_sudo:
print_info("Sudo password: configured")
elif prompt_yes_no(
"Enable sudo support? (stores password for apt install, etc.)", False
):
sudo_pass = prompt(" Sudo password", password=True)
if sudo_pass:
save_env_value("SUDO_PASSWORD", sudo_pass)
print_success("Sudo password saved")
# Gateway working directory defaults to home; sudo stays off. Both are
# configurable later via `hermes setup terminal` / config.yaml.
config["terminal"].setdefault("cwd", str(Path.home()))
elif selected_backend == "docker":
print_success("Terminal backend: Docker")
@@ -1373,13 +1196,10 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Docker found: {docker_bin}")
# Docker image
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Docker image", current_image)
config["terminal"]["docker_image"] = image
save_env_value("TERMINAL_DOCKER_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
@@ -1394,12 +1214,11 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Found: {sing_bin}")
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Container image", current_image)
config["terminal"]["singularity_image"] = image
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"singularity_image",
"docker://nikolaik/python-nodejs:python3.11-nodejs20",
)
elif selected_backend == "modal":
print_success("Terminal backend: Modal")
@@ -1498,8 +1317,6 @@ def setup_terminal_backend(config: dict):
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
_prompt_container_resources(config)
elif selected_backend == "daytona":
print_success("Terminal backend: Daytona")
print_info("Persistent cloud development environments.")
@@ -1549,13 +1366,10 @@ def setup_terminal_backend(config: dict):
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Configured")
# Daytona image
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Sandbox image", current_image)
config["terminal"]["daytona_image"] = image
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
@@ -1622,7 +1436,7 @@ def setup_terminal_backend(config: dict):
def _apply_default_agent_settings(config: dict):
"""Apply recommended defaults for all agent settings without prompting."""
config.setdefault("agent", {})["max_turns"] = 90
config.setdefault("agent", {})["max_turns"] = 150
# config.yaml is the authoritative source for max_turns; the gateway
# bridges it into HERMES_MAX_ITERATIONS at startup. We no longer write
# to .env to avoid the dual-source inconsistency that caused the
@@ -1634,18 +1448,17 @@ def _apply_default_agent_settings(config: dict):
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
config.setdefault("session_reset", {}).update({
"mode": "both",
"idle_minutes": 1440,
"at_hour": 4,
})
# Default to never auto-resetting sessions. The gateway treats absent
# session_reset as "both", so we must write "none" explicitly to make
# the no-auto-reset default actually take effect.
config.setdefault("session_reset", {})["mode"] = "none"
save_config(config)
print_success("Applied recommended defaults:")
print_info(" Max iterations: 90")
print_info(" Max iterations: 150")
print_info(" Tool progress: all")
print_info(" Compression threshold: 0.50")
print_info(" Session reset: inactivity (1440 min) + daily (4:00)")
print_info(" Session reset: never (use /reset or compression)")
print_info(" Run `hermes setup agent` later to customize.")
@@ -3194,7 +3007,7 @@ def run_setup_wizard(args):
config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)",
"Quick Setup (Nous Portal) — OAuth login, model & messaging (recommended)",
"Full setup — configure everything",
], 0)
@@ -3225,9 +3038,11 @@ def run_setup_wizard(args):
if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
setup_terminal_backend(config)
# Section 3: Agent Settings
if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
setup_agent_settings(config)
# Section 3: Agent Settings — no longer prompted. First installs get the
# recommended defaults silently; existing installs keep whatever they have.
# Tune later with `hermes setup agent`.
if not is_existing:
_apply_default_agent_settings(config)
# Section 4: Messaging Platforms
if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
@@ -3247,13 +3062,43 @@ def run_setup_wizard(args):
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
"""Streamlined first-time setup: provider, model, terminal & messaging.
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.
Applies sensible defaults for TTS (Edge), agent settings, and tools
the user can customize later via ``hermes setup <section>``.
Routes straight to the Nous Portal provider runs the device-code OAuth
login, picks a Nous model, then configures the terminal backend and (optionally)
a messaging platform. Applies sensible defaults for everything else (agent
settings, tools); the user can customize later via ``hermes setup <section>``
or switch providers with ``hermes model``.
"""
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True)
from hermes_cli.config import load_config
# Step 1: Nous Portal — OAuth login + model selection.
# _model_flow_nous() handles both the logged-out path (device-code OAuth,
# which selects a model internally) and the already-logged-in path (curated
# Nous model picker). Provider is set to "nous" by the login/model save.
print()
print_header("Nous Portal")
print_info("One subscription, 300+ models, plus the Tool Gateway:")
print_info(" web search, image generation, TTS, browser automation.")
print_info("Sign up: https://portal.nousresearch.com/manage-subscription")
print()
try:
from hermes_cli.main import _model_flow_nous
_model_flow_nous(config)
except (KeyboardInterrupt, EOFError):
print()
print_info("Nous Portal setup cancelled.")
except Exception as exc:
logger.debug("_model_flow_nous error during quick setup: %s", exc)
print_warning(f"Nous Portal setup encountered an error: {exc}")
print_info("You can try again later with: hermes model")
# Re-sync the wizard's config dict from disk — _model_flow_nous (and the
# underlying login/model save) write via their own load/save cycle, and the
# wizard's later save_config(config) must not clobber those values (#4172).
_refreshed = load_config()
config.clear()
config.update(_refreshed)
# Step 2: Terminal Backend — where commands run is a core decision
setup_terminal_backend(config)
+3 -4
View File
@@ -215,7 +215,7 @@ TIPS = [
# --- Context & Compression ---
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
"The status bar turns yellow, then orange, then red as context fills up.",
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
"SOUL.md is the agent's primary identity file — customize it to shape behavior.",
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
"Context files are capped at 20,000 characters with smart head/tail truncation.",
@@ -273,7 +273,7 @@ TIPS = [
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
"SOUL.md is auto-seeded with a default personality on first run. Edit it to customize.",
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
@@ -430,7 +430,7 @@ TIPS = [
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
'hermes chat --ignore-user-config skips the active user config — reproducible bug reports and CI runs.',
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
@@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
"""
return random.choice(TIPS)
+120 -50
View File
@@ -244,9 +244,16 @@ TOOL_CATEGORIES = {
],
"tts_provider": "elevenlabs",
},
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
# package is currently quarantined (malicious 2.4.6 release on
# 2026-05-12). Restore this entry once PyPI un-quarantines.
# Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use.
{
"name": "Mistral (Voxtral TTS)",
"badge": "paid",
"tag": "Multilingual, native Opus",
"env_vars": [
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
],
"tts_provider": "mistral",
},
{
"name": "Google Gemini TTS",
"badge": "preview",
@@ -339,11 +346,26 @@ TOOL_CATEGORIES = {
"video_gen": {
"name": "Video Generation",
"icon": "🎬",
# Providers list is intentionally empty — every video gen backend
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
# injected by ``_visible_providers``. Mirrors the design we'll
# converge image_gen toward.
"providers": [],
# "Nous Subscription" row mirrors the image_gen pattern — managed
# FAL video generation billed via the Nous Portal. Plugin-backed
# provider rows (FAL BYOK, xAI, …) are injected at runtime by
# ``_plugin_video_gen_providers()`` in ``_visible_providers``.
"providers": [
{
"name": "Nous Subscription",
"badge": "subscription",
"tag": "Managed FAL video generation billed to your subscription",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "video_gen",
"override_env_vars": ["FAL_KEY"],
# The underlying plugin backend — when the user picks
# "Nous Subscription" we set video_gen.provider = "fal"
# and video_gen.use_gateway = True so the FAL plugin
# routes through the managed queue gateway.
"video_gen_plugin_name": "fal",
},
],
},
"x_search": {
"name": "X (Twitter) Search",
@@ -1438,7 +1460,7 @@ def _toolset_has_keys(
except Exception:
return False
if ts_key in {"web", "image_gen", "tts", "browser"}:
if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
feature = features.features.get(ts_key)
if feature and (feature.available or feature.managed_by_nous):
@@ -1854,18 +1876,26 @@ def _visible_providers(
*,
force_fresh: bool = False,
) -> list[dict]:
"""Return provider entries visible for the current auth/config state."""
"""Return provider entries visible for the current auth/config state.
Nous-managed Tool Gateway rows (``managed_nous_feature``) are always
shown even to logged-out / unentitled users so the picker advertises
that the capability exists. Selecting one drives an inline Nous Portal
login + entitlement check (see ``_configure_provider``); the row only
*activates* the gateway once paid access is confirmed.
"""
features = get_nous_subscription_features(config, force_fresh=force_fresh)
managed_available = bool(
features.account_info
and features.account_info.logged_in
and features.account_info.paid_service_access is True
)
visible = []
for provider in cat.get("providers", []):
if provider.get("managed_nous_feature") and not managed_available:
continue
if provider.get("requires_nous_auth") and not features.nous_auth_present:
# Nous-managed Tool Gateway rows stay visible regardless of auth —
# selecting one drives an inline Portal login. A `requires_nous_auth`
# row that is NOT a managed gateway feature (pure pre-auth UX) is
# still hidden until the user is logged in.
if (
provider.get("requires_nous_auth")
and not provider.get("managed_nous_feature")
and not features.nous_auth_present
):
continue
visible.append(provider)
@@ -1911,22 +1941,16 @@ def _hidden_nous_gateway_message(
*,
force_fresh: bool = False,
) -> str:
"""Return a reason when a category's Nous provider is hidden."""
features = get_nous_subscription_features(config, force_fresh=force_fresh)
managed_available = bool(
features.account_info
and features.account_info.logged_in
and features.account_info.paid_service_access is True
)
if managed_available:
return ""
if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])):
return ""
message = format_nous_portal_entitlement_message(
features.account_info,
capability=capability,
)
return message or ""
"""Deprecated: Nous Tool Gateway rows are no longer hidden.
Previously this returned a "log in / upgrade" banner shown above a
category when its Nous-managed rows were filtered out for unentitled
users. Those rows are now always listed (see ``_visible_providers``), and
the login + entitlement guidance happens inline when the user selects one
(``ensure_nous_portal_access``). Kept as a no-op so call sites stay simple;
always returns an empty string.
"""
return ""
_POST_SETUP_INSTALLED: dict = {
@@ -2110,14 +2134,17 @@ def _configure_tool_category(
configured = ""
else:
configured = " [configured]"
# Highlight Nous-managed entries when the user has Portal auth.
# curses_radiolist can't render ANSI inside item strings, so we
# use a plain unicode star + parenthetical phrase. Suppressed
# when no Portal auth is present so non-subscribers see the
# picker unchanged.
# Mark Nous-managed entries. Logged-in paid subscribers get the
# "included" star; everyone else gets a "via Nous Portal" hint so
# it's clear selecting the row triggers a Portal login. The rows
# are always shown now (see _visible_providers) — selecting one
# drives an inline login + entitlement check.
sub_marker = ""
if _nous_logged_in and p.get("managed_nous_feature"):
sub_marker = " ★ Included with your Nous subscription"
if p.get("managed_nous_feature"):
if _nous_logged_in:
sub_marker = " ★ Included with your Nous subscription"
else:
sub_marker = " ★ via Nous Portal (login on select)"
provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}")
# Add skip option
@@ -2153,7 +2180,7 @@ def _is_provider_active(
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
video_plugin_name = provider.get("video_gen_plugin_name")
if video_plugin_name:
if video_plugin_name and not provider.get("managed_nous_feature"):
video_cfg = config.get("video_gen", {})
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
@@ -2172,6 +2199,15 @@ def _is_provider_active(
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
return False
return feature.managed_by_nous
if managed_feature == "video_gen":
video_cfg = config.get("video_gen", {})
if isinstance(video_cfg, dict):
configured_provider = video_cfg.get("provider")
if configured_provider not in {None, "", "fal"}:
return False
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
return False
return feature.managed_by_nous
if provider.get("tts_provider"):
return (
feature.managed_by_nous
@@ -2505,14 +2541,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None
_print_success(f" Model set to: {chosen}")
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed video generation provider selection."""
vid_cfg = config.setdefault("video_gen", {})
if not isinstance(vid_cfg, dict):
vid_cfg = {}
config["video_gen"] = vid_cfg
vid_cfg["provider"] = plugin_name
vid_cfg["use_gateway"] = False
vid_cfg["use_gateway"] = use_gateway
_print_success(f" video_gen.provider set to: {plugin_name}")
_configure_videogen_model_for_plugin(plugin_name, config)
@@ -2527,7 +2563,26 @@ def _configure_provider(
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
# Nous-managed Tool Gateway backends are always listed (see
# _visible_providers), but only *activate* once the user has paid Nous
# Portal access. Selecting one runs an inline Portal login when needed —
# auth + entitlement only, no inference-provider switch and no bulk
# "enable all tools" prompt (that lives in `hermes model`).
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
)
return
# Pure pre-auth UX rows (requires_nous_auth without a managed gateway
# feature) keep the old gate. Managed rows are handled by the inline
# login above, so don't double-check them here.
if provider.get("requires_nous_auth") and not managed_feature:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
entitled = bool(
features.account_info and features.account_info.paid_service_access is True
@@ -2597,7 +2652,7 @@ def _configure_provider(
# registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection after backend pick.
backend = provider.get("imagegen_backend")
@@ -2676,7 +2731,7 @@ def _configure_provider(
return
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection after env vars are in.
backend = provider.get("imagegen_backend")
@@ -2891,7 +2946,22 @@ def _reconfigure_provider(
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
# Same inline Nous Portal login + entitlement gate as _configure_provider:
# managed Tool Gateway backends only activate with paid Portal access.
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
)
return
# Pure pre-auth UX rows keep the old gate; managed rows already handled
# by the inline login above.
if provider.get("requires_nous_auth") and not managed_feature:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
entitled = bool(
features.account_info and features.account_info.paid_service_access is True
@@ -2957,7 +3027,7 @@ def _reconfigure_provider(
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection on reconfig too.
backend = provider.get("imagegen_backend")
@@ -2997,7 +3067,7 @@ def _reconfigure_provider(
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
backend = provider.get("imagegen_backend")
+54
View File
@@ -117,6 +117,49 @@ def remove_wrapper_script():
return removed
def remove_node_symlinks(hermes_home: Path) -> list:
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
creates::
~/.local/bin/node -> $HERMES_HOME/node/bin/node
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
manager such as nvm. Symmetrically remove them on uninstall, but *only*
when the link still resolves into this Hermes home's ``node`` directory.
A link the user has since repointed at nvm (or anything else outside
Hermes) is left untouched so we never break unrelated tooling.
"""
node_dir = (hermes_home / "node").resolve()
removed = []
for name in ("node", "npm", "npx"):
link = Path.home() / ".local" / "bin" / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
return removed
def uninstall_gateway_service():
"""Stop and uninstall the gateway service (systemd, launchd, Windows
Scheduled Task / Startup folder) and kill any standalone gateway processes.
@@ -594,6 +637,17 @@ def run_uninstall(args):
log_success(f"Removed {wrapper}")
else:
log_info("No wrapper script found")
# 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin
# (only when they still point into this Hermes home's node dir, so we
# never clobber an existing nvm / user-managed Node).
log_info("Removing Hermes-managed node/npm/npx symlinks...")
removed_node_links = remove_node_symlinks(hermes_home)
if removed_node_links:
for link in removed_node_links:
log_success(f"Removed {link}")
else:
log_info("No Hermes-managed node/npm/npx symlinks found")
# 4. Remove installation directory (code)
log_info("Removing installation directory...")
+19 -4
View File
@@ -320,9 +320,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"stt.provider": {
"type": "select",
"description": "Speech-to-text provider",
# "mistral" temporarily removed — mistralai PyPI package quarantined
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
"options": ["local", "openai"],
"options": ["local", "openai", "mistral"],
},
"display.skin": {
"type": "select",
@@ -3373,10 +3371,19 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""Check if the WebSocket client IP is acceptable.
Loopback mode: only loopback clients allowed the legacy
Loopback bind: only loopback clients allowed the legacy
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
don't want LAN hosts guessing tokens.
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
specific address such as a Tailscale/LAN IP, always with
``--insecure``): allow any peer. The operator explicitly opted into
non-loopback exposure, so the loopback-only peer restriction does not
apply. DNS-rebinding is still blocked by the Host/Origin guard in
:func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and
requires the Host header to match the bound interface the same
defence ``_is_accepted_host`` applies to non-loopback HTTP requests.
Gated mode: any peer is allowed uvicorn's ``proxy_headers=True``
(enabled when the OAuth gate is active so cookies can pick up
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
@@ -3387,6 +3394,14 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""
if getattr(app.state, "auth_required", False):
return True
# Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /
# Tailscale address) means the operator opted into non-loopback
# access via --insecure. The loopback-only peer gate only applies to
# an actual loopback bind; otherwise the WS handshake is rejected even
# though same-bind HTTP requests pass _is_accepted_host.
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return True
client_host = ws.client.host if ws.client else ""
if not client_host:
return True
+287 -62
View File
@@ -72,6 +72,15 @@ _last_init_error_lock = threading.Lock()
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
_FTS_TRIGGERS = (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
)
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
@@ -380,6 +389,8 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
try:
self._conn = sqlite3.connect(
str(self.db_path),
@@ -388,7 +399,6 @@ class SessionDB:
# handles contention instead of sitting in SQLite's internal
# busy handler for up to 30s.
timeout=1.0,
# Autocommit mode: Python's default isolation_level=""
# auto-starts transactions on DML, which conflicts with our
# explicit BEGIN IMMEDIATE. None = we manage transactions
# ourselves.
@@ -417,6 +427,111 @@ class SessionDB:
# ── Core write helper ──
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
return "no such module" in err and "fts5" in err
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
if self._fts_unavailable_warned:
return
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
exc,
)
def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool:
try:
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass
@staticmethod
def _fts_trigger_count(cursor: sqlite3.Cursor) -> int:
placeholders = ",".join("?" for _ in _FTS_TRIGGERS)
row = cursor.execute(
f"SELECT COUNT(*) FROM sqlite_master "
f"WHERE type = 'trigger' AND name IN ({placeholders})",
_FTS_TRIGGERS,
).fetchone()
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
@staticmethod
def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None:
for table_name in ("messages_fts", "messages_fts_trigram"):
cursor.execute(f"DELETE FROM {table_name}")
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]:
try:
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
return True
except sqlite3.OperationalError as exc:
if self._is_fts5_unavailable_error(exc):
self._warn_fts5_unavailable(exc)
return None
if "no such table" in str(exc).lower():
return False
raise
def _ensure_fts_schema(
self,
cursor: sqlite3.Cursor,
table_name: str,
ddl: str,
) -> bool:
status = self._fts_table_probe(cursor, table_name)
if status is None:
return False
try:
# Run even when the virtual table exists so any dropped or missing
# triggers are recreated after a previous no-FTS5 runtime disabled
# them to keep message writes working.
cursor.executescript(ddl)
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
@@ -629,6 +744,16 @@ class SessionDB:
except sqlite3.OperationalError as exc:
logger.debug("idx_messages_platform_msg_id create skipped: %s", exc)
fts5_available = self._sqlite_supports_fts5(cursor)
fts_migrations_complete = True
if not fts5_available:
# Existing FTS triggers can still fire on messages INSERT/UPDATE
# even though the current sqlite runtime cannot read the virtual
# tables they target. Drop only the triggers so core persistence
# continues; if a future runtime has FTS5, _ensure_fts_schema()
# recreates them.
self._drop_fts_triggers(cursor)
# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
@@ -650,17 +775,24 @@ class SessionDB:
# virtual table + triggers are created unconditionally via
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
# backfill into the FTS index.
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
_fts_trigram_exists = True
except sqlite3.OperationalError:
_fts_trigram_exists = False
if not _fts_trigram_exists:
cursor.executescript(FTS_TRIGRAM_SQL)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
if fts5_available:
_fts_trigram_exists = self._fts_table_probe(
cursor, "messages_fts_trigram"
)
if _fts_trigram_exists is False:
if self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
):
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
)
else:
fts_migrations_complete = False
elif _fts_trigram_exists is None:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < 11:
# v11: re-index FTS5 tables to cover tool_name + tool_calls and
# switch from external-content to inline mode. Existing DBs have
@@ -668,45 +800,50 @@ class SessionDB:
# overwrite, so we drop them explicitly and let the post-migration
# existence checks (below) recreate them from FTS_SQL /
# FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
for _trig in (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
except sqlite3.OperationalError:
pass
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError:
pass
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
cursor.executescript(FTS_SQL)
cursor.executescript(FTS_TRIGRAM_SQL)
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
if current_version < SCHEMA_VERSION:
if fts5_available:
self._drop_fts_triggers(cursor)
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
fts5_available = False
fts_migrations_complete = False
break
if fts5_available:
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
if (
self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
and self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
):
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
else:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < SCHEMA_VERSION and fts_migrations_complete:
cursor.execute(
"UPDATE schema_version SET version = ?",
(SCHEMA_VERSION,),
@@ -721,17 +858,22 @@ class SessionDB:
except sqlite3.OperationalError:
pass # Index already exists
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
try:
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_SQL)
if fts5_available:
# FTS5 setup. Run the DDL even when the virtual table exists so
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
# an earlier no-FTS5 runtime.
triggers_need_repair = self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
self._fts_enabled = self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
# Trigram FTS5 for CJK/substring search
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_TRIGRAM_SQL)
# Trigram FTS5 for CJK/substring search. This is optional relative
# to the main FTS table; if it cannot be created, CJK search falls
# back to LIKE.
if self._fts_enabled:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
if trigram_enabled and triggers_need_repair:
self._rebuild_fts_indexes(cursor)
self._conn.commit()
@@ -935,6 +1077,20 @@ class SessionDB:
)
self._execute_write(_do)
def update_session_model(self, session_id: str, model: str) -> None:
"""Update the model for a session after a mid-session switch.
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
(only filling in NULL), this unconditionally sets the model column
so that the dashboard reflects the user's latest /model choice.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model = ? WHERE id = ?",
(model, session_id),
)
self._execute_write(_do)
def update_token_counts(
self,
session_id: str,
@@ -2317,6 +2473,9 @@ class SessionDB:
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
FTS5 path.
"""
if not self._fts_enabled:
return []
if not query or not query.strip():
return []
@@ -3251,7 +3410,59 @@ class SessionDB:
# ── Space reclamation ──
def vacuum(self) -> None:
# FTS5 virtual tables whose b-tree segments we merge on optimize. The
# trigram table is created lazily / may be disabled, so we probe before
# touching it (see optimize_fts).
_FTS_TABLES = ("messages_fts", "messages_fts_trigram")
def _fts_table_exists(self, name: str) -> bool:
"""True if an FTS5 virtual table is queryable in this DB."""
try:
self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
return True
except sqlite3.OperationalError:
return False
def optimize_fts(self) -> int:
"""Merge fragmented FTS5 b-tree segments into one per index.
FTS5 indexes grow as a series of incremental segments one per
``INSERT`` batch driven by the message triggers. Over tens of
thousands of messages these segments accumulate, which both bloats
the ``*_data`` shadow tables and slows ``MATCH`` queries that must
scan every segment. The special ``'optimize'`` command rewrites each
index as a single merged segment.
This is purely a maintenance operation it changes neither search
results nor ``snippet()`` output, only on-disk layout and query
speed. It is complementary to VACUUM: ``optimize`` compacts the FTS
index internally, then VACUUM returns the freed pages to the OS.
Skips any FTS table that does not exist (e.g. the trigram index when
disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so
it is safe to call unconditionally.
Returns the number of FTS indexes that were optimized.
"""
optimized = 0
with self._lock:
for tbl in self._FTS_TABLES:
if not self._fts_table_exists(tbl):
continue
try:
# The column name in the INSERT must match the table name
# for FTS5 special commands.
self._conn.execute(
f"INSERT INTO {tbl}({tbl}) VALUES('optimize')"
)
optimized += 1
except sqlite3.OperationalError as exc:
logger.warning(
"FTS optimize failed for %s: %s", tbl, exc
)
return optimized
def vacuum(self) -> int:
"""Run VACUUM to reclaim disk space after large deletes.
SQLite does not shrink the database file when rows are deleted
@@ -3264,7 +3475,21 @@ class SessionDB:
exclusive lock, so callers must ensure no other writers are
active. Safe to call at startup before the gateway/CLI starts
serving traffic.
FTS5 segments are merged first via :meth:`optimize_fts` so the
subsequent VACUUM reclaims the pages freed by the merge. This is a
layout-only optimization search results are unchanged.
Returns the number of FTS indexes that were optimized (0 if the
merge step failed or no FTS tables exist).
"""
# Merge FTS5 segments before VACUUM so the freed pages are returned
# to the OS in the same pass. optimize_fts() manages its own lock.
optimized = 0
try:
optimized = self.optimize_fts()
except Exception as exc:
logger.warning("FTS optimize before VACUUM failed: %s", exc)
# VACUUM cannot be executed inside a transaction.
with self._lock:
# Best-effort WAL checkpoint first, then VACUUM.
@@ -3273,6 +3498,7 @@ class SessionDB:
except Exception:
pass
self._conn.execute("VACUUM")
return optimized
def maybe_auto_prune_and_vacuum(
self,
@@ -3446,4 +3672,3 @@ class SessionDB:
(error[:500], session_id),
)
self._execute_write(_do)
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Titel:** {title}"
created: "**Geskep:** {timestamp}"
last_activity: "**Laaste aktiwiteit:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
agent_running: "**Agent loop:** {state}"
state_yes: "Ja ⚡"
state_no: "Nee"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Titel:** {title}"
created: "**Erstellt:** {timestamp}"
last_activity: "**Letzte Aktivität:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
agent_running: "**Agent läuft:** {state}"
state_yes: "Ja ⚡"
state_no: "Nein"
+1 -1
View File
@@ -270,7 +270,7 @@ gateway:
title: "**Title:** {title}"
created: "**Created:** {timestamp}"
last_activity: "**Last Activity:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
agent_running: "**Agent Running:** {state}"
state_yes: "Yes ⚡"
state_no: "No"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Título:** {title}"
created: "**Creado:** {timestamp}"
last_activity: "**Última actividad:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
agent_running: "**Agente activo:** {state}"
state_yes: "Sí ⚡"
state_no: "No"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Título:** {title}"
created: "**Criada:** {timestamp}"
last_activity: "**Última atividade:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
agent_running: "**Agente em execução:** {state}"
state_yes: "Sim ⚡"
state_no: "Não"
+1 -1
View File
@@ -242,7 +242,7 @@
type = types.str;
default = "${cfg.stateDir}/workspace";
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
description = "Working directory for the agent (MESSAGING_CWD).";
description = "Working directory for the agent.";
};
# ── Declarative config ───────────────────────────────────────────────
-1
View File
@@ -43,7 +43,6 @@
"modal"
"parallel-web"
"tts-premium"
"vercel"
"voice"
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
@@ -0,0 +1,177 @@
---
name: antigravity-cli
description: "Operate the Antigravity CLI (agy): plugins, auth, sandbox."
version: 0.1.0
author: Tony Simons (asimons81), Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Antigravity, CLI, Auth, Plugins, Sandbox]
related_skills: [grok, codex, claude-code, hermes-agent]
---
# Antigravity CLI (`agy`)
Operator guide for the Antigravity CLI, invoked as `agy`. Run all `agy`
commands through the Hermes `terminal` tool; inspect its config and logs with
`read_file`. This skill is reference + procedure — it does not wrap a network
API, so there is nothing to authenticate from Hermes itself.
## When to Use
- Installing, updating, or smoke-testing the `agy` binary
- Driving non-interactive `agy --print` / `agy -p` one-shots
- Debugging Antigravity auth, sandbox, permissions, or plugin state
- Reading Antigravity settings, keybindings, conversations, or logs
## Mental model
Antigravity has two layers — keep them distinct or the guidance will be wrong:
1. **Shell wrapper commands**`agy help`, `agy install`, `agy plugin`,
`agy update`, `agy changelog`. Run these through the `terminal` tool.
2. **Interactive in-session slash commands**`/config`, `/permissions`,
`/skills`, `/agents`, etc. These only exist inside a running `agy` TUI
session, not on the shell wrapper.
`agy help` shows the shell wrapper surface, NOT the in-session slash commands.
## Prerequisites
- The `agy` binary on PATH. Verify through the `terminal` tool:
`command -v agy && agy --version`.
- No env vars or API keys required by this skill — Antigravity manages its own
auth via the OS keyring / browser sign-in (see Authentication below).
## How to Run
Invoke every `agy` command through the `terminal` tool. Examples:
```
terminal(command="agy --version")
terminal(command="agy help")
terminal(command="agy plugin list")
terminal(command="agy --print 'Summarize the repo in 3 bullets'", workdir="/path/to/project")
```
For an interactive multi-turn TUI session, launch `agy` with `pty=true` (and
tmux for capture/monitoring), the same pattern the `codex` / `claude-code`
skills use. For one-shot smoke tests and scripted prompts, prefer
`agy --print` (non-interactive).
To inspect Antigravity's own files, use `read_file` on the paths under Core
paths below — do not `cat` them through the terminal.
## Core paths
- Binary / entrypoint: `agy`
- App data dir: `~/.gemini/antigravity-cli/`
- Settings file: `~/.gemini/antigravity-cli/settings.json`
- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json`
- Logs: `~/.gemini/antigravity-cli/log/cli-*.log`
- Conversations: `~/.gemini/antigravity-cli/conversations/`
- Brain artifacts: `~/.gemini/antigravity-cli/brain/`
- History: `~/.gemini/antigravity-cli/history.jsonl`
- Plugin staging: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
## Quick Reference
### Wrapper commands
- `agy changelog`
- `agy help`
- `agy install`
- `agy plugin` / `agy plugins`
- `agy update`
### Useful flags
- `--add-dir`
- `--continue` / `-c`
- `--conversation`
- `--dangerously-skip-permissions`
- `--print` / `-p`
- `--print-timeout`
- `--prompt`
- `--prompt-interactive` / `-i`
- `--sandbox`
- `--log-file`
- `--version`
### Plugin subcommands (`agy plugin --help`)
- `list`, `import [source]`, `install <target>`, `uninstall <name>`,
`enable <name>`, `disable <name>`, `validate [path]`, `link <mp> <target>`,
`help`
### Install flags (`agy install --help`)
- `--dir`, `--skip-aliases`, `--skip-path`
### In-session slash commands
- **Conversation control:** `/resume` (`/switch`), `/rewind` (`/undo`),
`/rename <name>`, `/clear`, `/fork`, `/reset`, `/new`
- **Settings & tools:** `/config`, `/settings`, `/permissions`, `/model`,
`/keybindings`, `/statusline`, `/tasks`, `/skills`, `/mcp`, `/open <path>`,
`/usage`, `/logout`, `/agents`
- **Prompt helpers:** `@` path autocomplete, `esc esc` clears the prompt (when
not streaming), `!` runs a terminal command directly, `?` opens help
## Settings and permissions
### Common settings keys (`settings.json`)
- `allowNonWorkspaceAccess`
- `colorScheme`
- `permissions.allow`
- `trustedWorkspaces`
### Permission modes
`request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`.
### Sandbox behavior
- `enableTerminalSandbox` is a boolean in `settings.json`; default `false`.
- Launch-time overrides (`--sandbox`, `--dangerously-skip-permissions`) can
supersede persistent settings for the current session.
## Authentication behavior
- The CLI tries the OS secure keyring first.
- With no saved session, it falls back to browser-based Google sign-in.
- Locally it opens the default browser; over SSH it prints an authorization URL
and expects the auth code pasted back.
- `/logout` removes saved credentials.
## Plugins
- Plugins stage under `~/.gemini/antigravity-cli/plugins/<plugin_name>/`.
- They can bundle skills, agents, rules, MCP servers, and hooks.
- `agy plugin list` returning no imported plugins is a valid empty state.
## Pitfalls
- `agy help` shows wrapper commands, not interactive slash commands.
- `agy --version` is the safe non-interactive version check; `agy version` is
interactive and can fail without a real TTY.
- First place to look for failures: `~/.gemini/antigravity-cli/log/cli-*.log`
(read with `read_file`).
- Don't confuse persistent JSON settings with launch-time overrides.
- `~/.gemini/antigravity-cli/bin/agentapi` is a thin wrapper to `agy agentapi`.
- On WSL, token storage is file-based, so auth issues are usually local-file /
session-state problems, not browser-only problems.
- Workspace identity can depend on launch directory and the `.antigravitycli`
project marker.
## Verification
Confirm the install is real and usable, all through the `terminal` tool (read
files with `read_file`):
1. `terminal(command="command -v agy")`
2. `terminal(command="agy --version")`
3. `terminal(command="agy help")`
4. `terminal(command="agy plugin list")`
5. `read_file` on `~/.gemini/antigravity-cli/settings.json`
6. `read_file` on the latest `~/.gemini/antigravity-cli/log/cli-*.log`
7. If needed, `read_file` on `~/.gemini/antigravity-cli/keybindings.json`
## Support files
- `references/cli-docs.md` — condensed notes from the getting-started, usage,
and features docs.
@@ -0,0 +1,64 @@
# Antigravity CLI docs, condensed
Source pages reviewed:
- `/docs/cli-getting-started`
- `/docs/cli-using`
- `/docs/cli-features`
## Install
- macOS/Linux: `curl -fsSL https://antigravity.google/cli/install.sh | bash`
- Windows PowerShell: `irm https://antigravity.google/cli/install.ps1 | iex`
- Windows CMD: `curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd`
## Authentication
- Tries secure keyring first.
- If no saved session exists, falls back to browser-based Google sign-in.
- Local machine: opens the default browser.
- SSH/remote: prints a secure authorization URL, then expects the auth code to be pasted back.
- `/logout` removes saved credentials.
## Config and files
- Settings: `~/.gemini/antigravity-cli/settings.json`
- Keybindings: `~/.gemini/antigravity-cli/keybindings.json`
- Plugins: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
## Useful slash commands
- `/config`, `/settings`
- `/permissions`
- `/resume` / `/switch`
- `/rewind` / `/undo`
- `/rename <name>`
- `/model`
- `/keybindings`
- `/statusline`
- `/tasks`
- `/skills`
- `/mcp`
- `/open <path>`
- `/usage`
- `/logout`
- `/agents`
## Prompt helpers
- `@` path autocomplete
- `esc esc` clears prompt when not streaming
- `!` runs a terminal command
- `?` opens help / slash command list
## Permissions and sandbox
- Permission modes: `request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`
- Launch overrides: `--sandbox`, `--dangerously-skip-permissions`
- Sandbox setting: `enableTerminalSandbox` in `settings.json` (default `false`)
## Plugins
- Plugins can bundle skills, agents, rules, MCP servers, and hooks.
- They are staged locally and auto-discovered once installed.
## Subagents
- `/agents` opens the panel for active/completed subagents.
- Subagents can run in parallel and request approvals.
## Keybindings
- `~/.gemini/antigravity-cli/keybindings.json`
- Malformed JSON falls back to defaults for broken actions.
- Docs list default bindings for clear, submit, cancel, exit, suspend, editor, approval yes/no, navigation, clipboard, undo/redo, and newline insertion.
@@ -0,0 +1,301 @@
---
name: grok
description: "Delegate coding to xAI Grok Build CLI (features, PRs)."
version: 0.1.0
author: Matt Maximo (MattMaximo), Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Grok, xAI, Code-Review, Refactoring, Automation]
related_skills: [codex, claude-code, hermes-agent]
---
# Grok Build CLI — Hermes Orchestration Guide
Delegate coding tasks to [Grok Build](https://docs.x.ai/build/overview) (xAI's
autonomous coding agent CLI, the `grok` command) via the Hermes terminal. Grok
can read files, write code, run shell commands, spawn subagents, and manage git
workflows. It runs three ways: an interactive TUI, **headless** (`-p`), and as
an **ACP agent** over JSON-RPC.
This is the third sibling to `codex` and `claude-code`. The orchestration
pattern is nearly identical — **prefer headless `-p` for one-shots**, use a PTY
for interactive sessions.
## When to use
- Building features
- Refactoring
- PR reviews
- Batch issue fixing
- Any task where you'd otherwise reach for Codex / Claude Code but want Grok
## Prerequisites
- **Install (preferred):** `npm install -g @xai-official/grok`
- The official installer `curl -fsSL https://x.ai/cli/install.sh | bash` also
works, but the `x.ai` host is Cloudflare-walled in some environments. The
npm path avoids that dependency entirely.
- **Auth — SuperGrok / X Premium+ subscription (primary path):**
- Run `grok login` once → opens a browser for OAuth → token cached in
`~/.grok/auth.json`. This uses your **SuperGrok or X Premium+** subscription
(no per-token API billing).
- Check sign-in state by looking for `~/.grok/auth.json`, or run a cheap
headless smoke test: `grok --no-auto-update -p "Say ok."`
- In the TUI, `/logout` signs out and `/login` (or relaunching) signs back in.
- **No git repo required** — unlike Codex, Grok runs fine outside a git
directory (good for scratch/throwaway tasks).
- **Claude Code / AGENTS.md compatible with zero config** — Grok auto-reads
`CLAUDE.md`, `.claude/` (skills, agents, MCPs, hooks, rules), and the
`AGENTS.md` family. Existing project context just works.
> **API-key fallback (not the default for this user):** Grok also supports
> setting the `XAI_API_KEY` environment variable for pay-as-you-go billing
> via `api.x.ai`. Only use
> this if `grok login` / SuperGrok auth is unavailable. The subscription path
> (`grok login`) is the intended setup here.
## Two Orchestration Modes
### Mode 1: Headless (`-p`) — Non-Interactive (PREFERRED)
Runs a one-shot task, prints the result, and exits. No PTY, no interactive
dialogs to navigate. This is the cleanest integration path — the analog of
`claude -p` and `codex exec`.
```
terminal(command="grok --no-auto-update -p 'Add a dark mode toggle to settings'", workdir="/path/to/project", timeout=180)
```
Always pass `--no-auto-update` in automation to skip background update checks.
**When to use headless:**
- One-shot coding tasks (fix a bug, add a feature, refactor)
- CI/CD automation and scripting
- Structured output parsing with `--output-format json`
- Any task that doesn't need multi-turn conversation
### Mode 2: Interactive PTY — Multi-Turn TUI Sessions
The TUI is a fullscreen, mouse-interactive app. Drive it with `pty=true`. For
robust monitoring/input use tmux (same pattern as the `claude-code` skill).
```
# Launch in a tmux session for capture-pane monitoring
terminal(command="tmux new-session -d -s grok-work -x 140 -y 40")
terminal(command="tmux send-keys -t grok-work 'cd /path/to/project && grok' Enter")
# Wait for startup, then send a task
terminal(command="sleep 5 && tmux send-keys -t grok-work 'Refactor the auth module to use JWT' Enter")
# Monitor progress
terminal(command="sleep 15 && tmux capture-pane -t grok-work -p -S -50")
# Exit when done
terminal(command="tmux send-keys -t grok-work '/quit' Enter && sleep 1 && tmux kill-session -t grok-work")
```
**Tip for headless-but-inline output:** if you want TUI-style output without the
fullscreen alt-screen takeover (e.g. for cleaner logs), add `--no-alt-screen`.
For pure automation, headless `-p` is still cleaner than the TUI.
## Headless Deep Dive
### Common Flags
| Flag | Effect |
|------|--------|
| `-p, --single <PROMPT>` | Send one prompt, run headless, exit |
| `-m, --model <MODEL>` | Choose a model |
| `-s, --session-id <ID>` | Create or resume a named headless session |
| `-r, --resume <ID>` | Resume an existing session |
| `-c, --continue` | Continue the most recent session in the current directory |
| `--cwd <PATH>` | Set the working directory |
| `--output-format <FMT>` | `plain` (default), `json`, or `streaming-json` |
| `--always-approve` | Auto-approve all tool executions (the `--full-auto` / `--yolo` equivalent) |
| `--no-alt-screen` | Run inline, no fullscreen TUI takeover |
| `--no-auto-update` | Skip background update checks (use in all automation) |
### Output Formats
- `plain` — human-readable text (default)
- `json` — one JSON object at the end of the run (parse the result cleanly)
- `streaming-json` — newline-delimited JSON events as they arrive
```
# Structured result for parsing
terminal(command="grok --no-auto-update -p 'List all TODO comments in src/' --output-format json", workdir="/project", timeout=120)
# Auto-approve for autonomous building
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the database layer and run the tests'", workdir="/project", timeout=300)
```
### Background Mode (Long Tasks)
```
# Start headless in background
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the auth module'", workdir="/project", background=true, notify_on_complete=true)
# Returns session_id
# Monitor
process(action="poll", session_id="<id>")
process(action="log", session_id="<id>")
# Kill if needed
process(action="kill", session_id="<id>")
```
For an interactive (TUI) background session, use `pty=true` + tmux and monitor
with `tmux capture-pane`, exactly like the `claude-code` / `codex` skills.
### Session Continuation
```
# Start a named session
terminal(command="grok --no-auto-update -s refactor-db -p 'Start refactoring the database layer' --always-approve", workdir="/project", timeout=240)
# Resume it later
terminal(command="grok --no-auto-update -r refactor-db -p 'Now add connection pooling' --always-approve", workdir="/project", timeout=180)
# Or continue the most recent session in this directory
terminal(command="grok --no-auto-update -c -p 'What did you change last time?'", workdir="/project", timeout=60)
```
## Read-Only Audit → Markdown Note Pattern
To have Grok review local artifacts and return a clean markdown note (for
Obsidian or a repo) without mutating anything:
1. Prepare stable input files first with Hermes tools (`read_file`,
`write_file`). Snapshot only the relevant context into a temp file rather
than dumping raw paths.
2. Run Grok headless **without** `--always-approve` so it cannot auto-write, and
demand `markdown only, no preamble`.
3. Save Grok's stdout straight into the destination note with `write_file()`.
```
grok --no-auto-update -p "Read /tmp/current.md and /tmp/inventory.md. Produce markdown only, no preamble. Output a clean note titled 'Cleanup Review'." --output-format plain
```
**Pitfall (same as Claude Code):** for document rewrites, a loose "rewrite this"
prompt may return a change summary instead of the full file. Instead: pipe the
file in, and demand `Return ONLY the full revised markdown document. No intro,
no explanation, no code fences. Start immediately with '# Title'.` Verify the
first lines with `read_file()` before overwriting the destination.
## PR Review Patterns
### Quick Review (Headless)
```
terminal(command="cd /path/to/repo && git diff main...feature-branch | grok --no-auto-update -p 'Review this diff for bugs, security issues, and style problems. Be thorough.'", timeout=120)
```
### Clone-to-temp Review (safe, no repo mutation)
```
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && grok --no-auto-update -p 'Review the changes vs origin/main. Check bugs, security, race conditions, missing tests.'", pty=true, timeout=300)
```
### Post the review
```
terminal(command="gh pr comment 42 --body '<review text>'", workdir="/path/to/repo")
```
## Parallel Issue Fixing with Worktrees
```
# Create worktrees
terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project")
terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project")
# Launch Grok headless in each (background)
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #78: <description>. Commit when done.'", workdir="/tmp/issue-78", background=true, notify_on_complete=true)
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #99: <description>. Commit when done.'", workdir="/tmp/issue-99", background=true, notify_on_complete=true)
# Monitor
process(action="list")
# After completion: push and open PRs
terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78")
terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'")
# Cleanup
terminal(command="git worktree remove /tmp/issue-78", workdir="~/project")
```
## Useful Subcommands & TUI Commands
| Command | Purpose |
|---------|---------|
| `grok` | Start the interactive TUI |
| `grok -p "query"` | Headless one-shot |
| `grok login` / `grok logout` | Sign in / out (SuperGrok / X Premium+ OAuth) |
| `grok inspect` | Show what Grok discovered in cwd: config sources, instructions, skills, plugins, hooks, MCP servers |
| `grok agent stdio` | Run as an ACP agent over JSON-RPC (for IDE/tool integration) |
| `grok update` | Update the CLI (needs the `x.ai` host; skip in automation) |
TUI slash commands (interactive only): `/model <name>`, `/always-approve`,
`/plan`, `/context`, `/compact`, `/resume`, `/sessions`, `/fork`, `/usage`,
`/quit`. `Shift+Tab` cycles session modes (including Plan mode, which blocks
write tools except the session plan file).
## Config (`~/.grok/config.toml`)
```toml
[cli]
auto_update = false # skip background update checks persistently
[ui]
permission_mode = "ask" # or "always-approve" to skip tool prompts by default
[models]
default = "grok-build-0.1"
```
Put global preferences in `~/.grok/config.toml` (not project-scoped
`.grok/config.toml`). `permission_mode` supersedes the legacy `approval_mode` /
`yolo = true` keys.
## Pitfalls & Gotchas
1. **Auth is subscription-gated.** `grok login` requires a SuperGrok or X
Premium+ subscription. If login fails or there's no `~/.grok/auth.json`,
confirm the subscription is active before falling back to `XAI_API_KEY`.
2. **Don't conflate Hermes' xAI auth with the `grok` CLI's auth.** Hermes'
`x_search` runs on its own xAI OAuth; the standalone `grok` CLI has a
separate token in `~/.grok/auth.json`. A working `x_search` does NOT mean
`grok` is logged in.
3. **Always pass `--no-auto-update` in automation** — otherwise Grok phones home
for update checks (and `x.ai`/`storage.googleapis.com` may be unreachable).
4. **Prefer npm install over the curl installer** — `npm install -g
@xai-official/grok` avoids the Cloudflare-walled `x.ai` host.
5. **`--always-approve` is the autonomous-build switch.** Without it, headless
runs may stall waiting on tool-approval prompts. Omit it deliberately for
read-only review/audit work so Grok can't mutate files.
6. **Headless `-p` skips TUI dialogs**; the TUI needs `pty=true` (+ tmux for
monitoring), just like Claude Code.
7. **Use `--no-alt-screen`** if you run the TUI inline and the fullscreen
alt-screen takeover garbles captured output.
8. **No git repo needed**, but for PR/commit workflows you still want one — use
`mktemp -d && git init` for scratch commit tasks.
9. **Clean up tmux sessions** with `tmux kill-session -t <name>` when done.
## Rules for Hermes Agents
1. **Prefer headless `-p`** for single tasks — cleanest integration, structured
output via `--output-format json`.
2. **Always set `workdir`** (or `--cwd`) so Grok targets the right project.
3. **Pass `--no-auto-update`** in every automated invocation.
4. **Use `--always-approve` only when Grok should write autonomously**; omit it
for read-only reviews and audits.
5. **Background long tasks** with `background=true, notify_on_complete=true` and
monitor via the `process` tool.
6. **Use tmux for multi-turn interactive work** and monitor with
`tmux capture-pane -t <session> -p -S -50`.
7. **Verify auth before relying on it** — check `~/.grok/auth.json` or run a
cheap `grok -p "Say ok."` smoke test; don't assume Hermes' xAI auth carries
over.
8. **Report results to the user** — summarize what Grok changed and what's left.
@@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is
### Cloud (app.honcho.dev)
```bash
hermes honcho setup
hermes memory setup honcho
# select "cloud", paste API key from https://app.honcho.dev
```
### Self-hosted
```bash
hermes honcho setup
hermes memory setup honcho
# select "local", enter base URL (e.g. http://localhost:8000)
```
@@ -38,7 +38,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
+11 -4
View File
@@ -119,17 +119,20 @@ class BrowserUseBrowserProvider(BrowserProvider):
return "Browser Use"
def is_available(self) -> bool:
return self._get_config_or_none() is not None
return self._get_config_or_none(refresh_token=False) is not None
# ------------------------------------------------------------------
# Config resolution (direct API key OR managed Nous gateway)
# ------------------------------------------------------------------
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
# Import here to avoid a hard dependency at module-import time —
# managed_tool_gateway pulls in the Nous auth stack which can be
# heavy and is not needed for direct-API-key users.
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.managed_tool_gateway import (
peek_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import prefers_gateway
# Direct API key wins unless the user has explicitly opted into the
@@ -142,7 +145,11 @@ class BrowserUseBrowserProvider(BrowserProvider):
"managed_mode": False,
}
managed = resolve_managed_tool_gateway("browser-use")
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
)
if managed is None:
return None
+1 -1
View File
@@ -26,7 +26,7 @@ Optional feature knobs::
BROWSERBASE_PROXIES=true # default true
BROWSERBASE_ADVANCED_STEALTH=false
BROWSERBASE_KEEP_ALIVE=true # default true
BROWSERBASE_SESSION_TIMEOUT=... (ms, integer)
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
"""
from __future__ import annotations
+8 -1
View File
@@ -481,7 +481,14 @@ def guess_category(path: Path) -> Optional[str]:
}:
return None
if top == "cron" or top == "cronjobs":
return "cron-output"
# Only files under the disposable ``output/`` subtree are
# cleanup candidates. Top-level cron control-plane state
# (e.g. ``jobs.json``, ``.tick.lock``) must never be
# auto-tracked — deleting it wipes the live scheduler
# registry. See issue #32164.
if len(rel.parts) >= 2 and rel.parts[1] == "output":
return "cron-output"
return None
if top == "cache":
return "temp"
except ValueError:
+209
View File
@@ -2600,6 +2600,13 @@
// input here to save vertical space in the common `scratch` case.
const [workspaceKind, setWorkspaceKind] = useState("scratch");
const [workspacePath, setWorkspacePath] = useState("");
// Goal-mode: when on, the dispatched worker runs the Ralph-style /goal
// loop — a judge re-checks the card after each turn and the worker keeps
// going in the same session until done, or the turn budget runs out
// (which blocks the card for review). goalMaxTurns is optional; blank
// = backend default.
const [goalMode, setGoalMode] = useState(false);
const [goalMaxTurns, setGoalMaxTurns] = useState("");
const submit = function () {
const trimmed = title.trim();
@@ -2626,9 +2633,17 @@
}
const wpTrim = workspacePath.trim();
if (wpTrim) body.workspace_path = wpTrim;
// Goal-mode toggle. Only send the keys when enabled so the request
// shape stays small and old dispatchers ignore it cleanly.
if (goalMode) {
body.goal_mode = true;
const gmt = parseInt(goalMaxTurns, 10);
if (Number.isFinite(gmt) && gmt > 0) body.goal_max_turns = gmt;
}
props.onSubmit(body);
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
setWorkspaceKind("scratch"); setWorkspacePath("");
setGoalMode(false); setGoalMaxTurns("");
};
const showPathInput = workspaceKind !== "scratch";
@@ -2685,6 +2700,29 @@
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
className: "h-7 text-xs",
}),
h("div", { className: "flex gap-2 items-center" },
h("label", {
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
},
h("input", {
type: "checkbox",
checked: goalMode,
onChange: function (e) { setGoalMode(!!e.target.checked); },
className: "h-3.5 w-3.5 accent-current",
}),
tx(t, "goalMode", "goal mode"),
),
goalMode ? h(Input, {
type: "number",
value: goalMaxTurns,
onChange: function (e) { setGoalMaxTurns(e.target.value); },
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
className: "h-7 text-xs w-40",
title: "Turn budget for the goal loop. Blank = backend default (20).",
min: 1,
}) : null,
),
h("div", { className: "flex gap-2" },
h(Select, Object.assign({
value: workspaceKind,
@@ -2741,6 +2779,8 @@
// Ready/Block/Complete buttons feel like no-ops. See #26744.
const [patchErr, setPatchErr] = useState(null);
const [newComment, setNewComment] = useState("");
const [uploadBusy, setUploadBusy] = useState(false);
const [uploadErr, setUploadErr] = useState(null);
const [editing, setEditing] = useState(false);
// Home-channel notification toggles. homeChannels is the list of platforms
// the user has a /sethome on; each entry has a `subscribed` bool telling
@@ -2789,6 +2829,49 @@
}).catch(function (e) { setErr(String(e.message || e)); });
};
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
// so the browser sets the multipart boundary. Auth rides the session
// cookie + bearer token, matching the rest of the dashboard.
const handleUpload = function (fileList) {
const files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
setUploadBusy(true);
setUploadErr(null);
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
// Upload sequentially so a partial failure leaves a clear state.
let chain = Promise.resolve();
files.forEach(function (f) {
chain = chain.then(function () {
const fd = new FormData();
fd.append("file", f, f.name);
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
});
});
});
chain.then(function () {
load();
props.onRefresh();
}).catch(function (e) {
setUploadErr(String(e.message || e));
}).finally(function () {
setUploadBusy(false);
});
};
const handleDeleteAttachment = function (attachmentId) {
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setUploadErr(String(e.message || e)); });
};
const doPatch = function (patch, opts) {
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
@@ -2946,6 +3029,10 @@
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
onUpload: handleUpload,
onDeleteAttachment: handleDeleteAttachment,
uploadBusy: uploadBusy,
uploadErr: uploadErr,
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
@@ -2968,11 +3055,118 @@
);
}
function _fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
// Attachments section in the task drawer (#35338). Upload button +
// list with download links and a delete (×) per row. The download
// link hits GET /attachments/:id which streams the file; the worker
// context surfaces the same files' absolute paths so a kanban worker
// can read them with the file/terminal tools.
function AttachmentsSection(props) {
const i18n = props.i18n;
const atts = props.attachments || [];
const fileRef = useRef(null);
const [dlErr, setDlErr] = useState(null);
// Download via authenticated fetch → blob → synthetic anchor click.
// A plain <a href> can't carry the session header/bearer the dashboard
// auth middleware requires in loopback mode, so fetch with the token
// and hand the browser a blob URL instead.
function downloadAttachment(a) {
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
setDlErr(null);
fetch(url, { headers: headers, credentials: "same-origin" })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
return resp.blob();
})
.then(function (blob) {
const objUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objUrl;
link.download = a.filename || "attachment";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
})
.catch(function (e) { setDlErr(String(e.message || e)); });
}
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
h("input", {
ref: fileRef,
type: "file",
multiple: true,
style: { display: "none" },
onChange: function (e) {
if (props.onUpload) props.onUpload(e.target.files);
// Reset so selecting the same file again re-triggers onChange.
try { e.target.value = ""; } catch (_e) { /* ignore */ }
},
}),
h("div", { className: "flex items-center gap-2 mb-2" },
h(Button, {
size: "sm",
variant: "outline",
disabled: !!props.uploadBusy,
onClick: function () { if (fileRef.current) fileRef.current.click(); },
}, props.uploadBusy
? tx(i18n, "uploading", "Uploading…")
: tx(i18n, "uploadFile", "Upload file")),
),
(props.uploadErr || dlErr)
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
: null,
atts.length === 0
? h("div", { className: "text-xs text-muted-foreground" },
tx(i18n, "noAttachments", "— no attachments —"))
: atts.map(function (a) {
return h("div", {
key: a.id,
className: "flex items-center justify-between gap-2 py-1 text-sm",
},
h("button", {
type: "button",
className: "hermes-kanban-attachment-link truncate",
title: a.filename,
onClick: function () { downloadAttachment(a); },
}, a.filename),
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
_fmtBytes(a.size)),
h("button", {
type: "button",
className: "hermes-kanban-drawer-close",
title: tx(i18n, "removeAttachment", "Remove attachment"),
onClick: function () {
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
"Remove this attachment?"))) {
if (props.onDelete) props.onDelete(a.id);
}
},
}, "×"),
);
}),
);
}
function TaskDetail(props) {
const { t: i18n } = useI18n();
const t = props.data.task;
const comments = props.data.comments || [];
const events = props.data.events || [];
const attachments = props.data.attachments || [];
const links = props.data.links || { parents: [], children: [] };
return h("div", { className: "hermes-kanban-drawer-body" },
@@ -3005,6 +3199,12 @@
label: tx(i18n, "skills", "Skills"),
value: t.skills.join(", "),
}) : null,
t.goal_mode ? h(MetaRow, {
label: tx(i18n, "goalMode", "Goal mode"),
value: t.goal_max_turns
? `on (max ${t.goal_max_turns} turns)`
: "on",
}) : null,
t.created_by ? h(MetaRow, { label: tx(i18n, "createdBy", "Created by"), value: t.created_by }) : null,
),
h(StatusActions, {
@@ -3042,6 +3242,15 @@
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
) : null,
h(AttachmentsSection, {
attachments: attachments,
boardSlug: props.boardSlug,
onUpload: props.onUpload,
onDelete: props.onDeleteAttachment,
uploadBusy: props.uploadBusy,
uploadErr: props.uploadErr,
i18n: i18n,
}),
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "comments", "Comments")} (${comments.length})`),
+44
View File
@@ -334,6 +334,11 @@
.hermes-kanban-drawer {
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
height: 100vh;
/* Dynamic viewport unit excludes the mobile browser's collapsing chrome
(URL/nav bars) so the drawer's bottom row stays reachable. Falls back to
100vh on browsers without dvh support. */
height: 100dvh;
max-height: 100dvh;
background: var(--color-card);
border-left: 1px solid var(--color-border);
display: flex;
@@ -352,10 +357,23 @@
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
/* Honor the top safe-area inset (notch) so the task id / close button are
not clipped on mobile. */
padding-top: max(0.6rem, env(safe-area-inset-top));
border-bottom: 1px solid var(--color-border);
font-family: var(--font-mono, ui-monospace, monospace);
}
/* On mobile the dashboard shell renders a fixed top bar (min-h-14, hidden at
the lg breakpoint). The drawer is a body-level z-60 overlay starting at the
viewport top, so its header would sit behind that bar. Push the header down
by the bar height (3.5rem) plus the top safe-area inset. */
@media (max-width: 1023px) {
.hermes-kanban-drawer-head {
padding-top: calc(3.5rem + env(safe-area-inset-top));
}
}
.hermes-kanban-drawer-close {
appearance: none;
background: transparent;
@@ -368,10 +386,33 @@
}
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
/* Attachment download trigger styled as a link, rendered as a <button>
so the click handler can fetch with the session token (#35338). */
.hermes-kanban-attachment-link {
appearance: none;
background: transparent;
border: 0;
padding: 0;
margin: 0;
text-align: left;
color: var(--color-primary, #6ea8fe);
cursor: pointer;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
.hermes-kanban-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
/* When no comment row is rendered (loading / error states), the scrolling
body is the bottom-most element extend its bottom padding past the
mobile browser chrome so the last content stays readable. */
padding-bottom: max(0.9rem, calc(0.9rem + env(safe-area-inset-bottom)));
display: flex;
flex-direction: column;
gap: 0.85rem;
@@ -530,6 +571,9 @@
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
/* Keep the comment input clear of the mobile browser nav bar / home
indicator by extending the bottom padding with the safe-area inset. */
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card) 90%, transparent);
}
+182 -1
View File
@@ -43,9 +43,11 @@ import os
import sqlite3
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from hermes_cli import kanban_db
@@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
}
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
"""Serialise an Attachment for the drawer. ``stored_path`` is the
absolute on-disk path workers read; the UI uses ``id`` for download."""
return {
"id": a.id,
"task_id": a.task_id,
"filename": a.filename,
"content_type": a.content_type,
"size": a.size,
"uploaded_by": a.uploaded_by,
"stored_path": a.stored_path,
"created_at": a.created_at,
}
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
"""Serialise a Run for the drawer's Run history section."""
return {
@@ -531,6 +548,7 @@ def get_task(
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
"links": _links_for(conn, task_id),
"runs": [
_run_dict(r)
@@ -563,6 +581,8 @@ class CreateTaskBody(BaseModel):
idempotency_key: Optional[str] = None
max_runtime_seconds: Optional[int] = None
skills: Optional[list[str]] = None
goal_mode: bool = False
goal_max_turns: Optional[int] = None
@router.post("/tasks")
@@ -585,6 +605,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
idempotency_key=payload.idempotency_key,
max_runtime_seconds=payload.max_runtime_seconds,
skills=payload.skills,
goal_mode=payload.goal_mode,
goal_max_turns=payload.goal_max_turns,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
@@ -609,6 +631,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
conn.close()
# ---------------------------------------------------------------------------
# Attachments — upload / list / download / delete (#35338)
# ---------------------------------------------------------------------------
# Cap a single upload so a runaway request can't fill the disk. 25 MB
# comfortably covers PDFs, images, and source docs — the kanban use case.
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
def _safe_attachment_name(raw: str) -> str:
"""Reduce a client-supplied filename to a safe basename.
Strips any directory components (``os.path.basename`` on both
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
to its leaf. Rejects empty / dotfile-only names. The result is only
ever joined under the per-task attachments dir, never used verbatim
as a path from the client.
"""
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
# Drop control chars and leading dots so we never write a dotfile or
# a name with embedded NULs/newlines.
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
name = name.lstrip(".").strip()
if not name:
raise HTTPException(status_code=400, detail="invalid attachment filename")
return name[:200]
@router.get("/tasks/{task_id}/attachments")
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return {
"attachments": [
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
]
}
finally:
conn.close()
@router.post("/tasks/{task_id}/attachments")
async def upload_task_attachment(
task_id: str,
file: UploadFile = File(...),
board: Optional[str] = Query(None),
uploaded_by: Optional[str] = Form(None),
):
"""Store an uploaded file for a task and record its metadata.
The blob lands under ``attachments_root(board)/<task_id>/`` with a
sanitised, collision-resolved name. The worker reads it via the
absolute path surfaced in ``build_worker_context``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
safe_name = _safe_attachment_name(file.filename or "")
# Stream to disk with a hard size cap so a huge upload can't fill
# the disk. Read in chunks; abort + clean up if the cap is hit.
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
dest_dir.mkdir(parents=True, exist_ok=True)
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
stem, dot, ext = safe_name.partition(".")
candidate = safe_name
n = 1
while (dest_dir / candidate).exists():
candidate = f"{stem} ({n}){dot}{ext}"
n += 1
dest_path = dest_dir / candidate
total = 0
try:
with open(dest_path, "wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > _MAX_ATTACHMENT_BYTES:
out.close()
dest_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=(
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
),
)
out.write(chunk)
except HTTPException:
raise
except OSError as exc:
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
att_id = kanban_db.add_attachment(
conn,
task_id,
filename=candidate,
stored_path=str(dest_path.resolve()),
content_type=file.content_type,
size=total,
uploaded_by=(uploaded_by or "dashboard"),
)
att = kanban_db.get_attachment(conn, att_id)
return {"attachment": _attachment_dict(att) if att else None}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
conn.close()
@router.get("/attachments/{attachment_id}")
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.get_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
# Confirm the blob still lives under the board's attachments root
# before serving — defense in depth against a tampered DB row.
root = kanban_db.attachments_root(board=board).resolve()
try:
stored = Path(att.stored_path).resolve()
stored.relative_to(root)
except (ValueError, OSError):
raise HTTPException(status_code=404, detail="attachment file unavailable")
if not stored.is_file():
raise HTTPException(status_code=404, detail="attachment file missing on disk")
return FileResponse(
path=str(stored),
filename=att.filename,
media_type=att.content_type or "application/octet-stream",
)
finally:
conn.close()
@router.delete("/attachments/{attachment_id}")
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.delete_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
return {"ok": True, "id": attachment_id}
finally:
conn.close()
# ---------------------------------------------------------------------------
# PATCH /tasks/:id (status / assignee / priority / title / body)
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def post_setup(self, hermes_home: str, config: dict) -> None:
"""Custom setup wizard — installs only the deps needed for the selected mode."""
+13 -8
View File
@@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
## Setup
```bash
hermes honcho setup # full interactive wizard (cloud or local)
hermes memory setup # generic picker, also works
hermes memory setup honcho # configure Honcho directly (works on a fresh install)
hermes memory setup # generic picker, choose Honcho from the list
```
Or manually:
@@ -22,6 +22,10 @@ hermes config set memory.provider honcho
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env
```
> `hermes honcho setup` also works, but only **after** Honcho is the active
> memory provider — the `honcho` subcommand is registered for the active
> provider only. On a fresh install, use `hermes memory setup honcho`.
## Architecture Overview
### Two-Layer Context Injection
@@ -109,7 +113,7 @@ Config is read from the first file that exists:
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
For every key, resolution order is: **host block > root > env var > default**.
@@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
- **Single-operator**`pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
- **Multi-user gateway**`pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
@@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
"recallMode": "hybrid",
"sessionStrategy": "per-directory"
},
"hermes.coder": {
"hermes_coder": {
"aiPeer": "coder",
"recallMode": "tools",
"sessionStrategy": "per-repo"
@@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` host key `hermes.coder`).
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
### Dialectic & Reasoning
@@ -307,7 +311,8 @@ Presets:
| Command | Description |
|---------|-------------|
| `hermes honcho setup` | Full interactive setup wizard |
| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |
| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |
| `hermes honcho status` | Show resolved config for active profile |
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
| `hermes honcho mode <mode>` | Change recall or observation mode |
@@ -344,7 +349,7 @@ Presets:
"dialecticMaxChars": 600,
"saveMessages": true
},
"hermes.coder": {
"hermes_coder": {
"enabled": true,
"aiPeer": "coder",
"sessionStrategy": "per-repo",
+3 -1
View File
@@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider):
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
import json
import os
from pathlib import Path
config_path = Path(hermes_home) / "honcho.json"
existing = {}
@@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def get_config_schema(self):
return [
+53 -18
View File
@@ -11,7 +11,7 @@ import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
from hermes_cli.config import cfg_get
@@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
if not default_block and not has_key:
return False
new_host = f"{HOST}.{profile_name}"
new_host = profile_host_key(profile_name)
if new_host in hosts:
return False # already exists
@@ -192,7 +192,7 @@ def cmd_sync(args) -> None:
if p.name == "default":
continue
if clone_honcho_for_profile(p.name):
print(f" + {p.name} -> hermes.{p.name}")
print(f" + {p.name} -> {profile_host_key(p.name)}")
created += 1
else:
skipped += 1
@@ -243,7 +243,7 @@ def _host_key() -> str:
if _profile_override:
if _profile_override in {"default", "custom"}:
return HOST
return f"{HOST}.{_profile_override}"
return profile_host_key(_profile_override)
return resolve_active_host()
@@ -275,10 +275,8 @@ def _read_config() -> dict:
def _write_config(cfg: dict, path: Path | None = None) -> None:
path = path or _local_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
from utils import atomic_json_write
atomic_json_write(path, cfg, mode=0o600)
def _resolve_api_key(cfg: dict) -> str:
@@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str:
config shapes, e.g. ``localhost:8000``) still pass the Honcho SDK
will reject them itself with a clearer error than ours.
"""
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
host_key = _host_block(cfg, _host_key()).get("apiKey")
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
if not key:
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
@@ -462,21 +460,58 @@ def cmd_setup(args) -> None:
cfg.pop("base_url", None)
if is_local:
# --- Local: ask for base URL, skip or clear API key ---
# --- Local: ask for base URL, optionally accept a JWT for auth ---
current_url = cfg.get("baseUrl") or ""
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
if new_url:
cfg["baseUrl"] = new_url
# For local no-auth, the SDK must not send an API key.
# We keep the key in config (for cloud switching later) but
# the client should skip auth when baseUrl is local.
current_key = cfg.get("apiKey", "")
if current_key:
print(f"\n API key present in config (kept for cloud/hybrid use).")
print(" Local connections will skip auth automatically.")
# Self-hosted Honcho can run with AUTH_USE_AUTH=true and an
# AUTH_JWT_SECRET on the server side. In that case clients must
# send a JWT signed with that secret as the bearer token (the
# Honcho SDK takes it via ``api_key=``). Cloud users got prompted
# for a key already; the local path historically skipped this and
# forced users to disable auth on the server. Offer the prompt
# here too. We store it under the host block (not the top-level
# apiKey) so ``get_honcho_client`` recognises it as an explicit
# local auth opt-in (see ``_host_has_key`` in client.py) and
# cloud/hybrid switching is unaffected.
current_host_key = hermes_host.get("apiKey", "")
masked = (
f"...{current_host_key[-8:]}"
if len(current_host_key) > 8
else ("set" if current_host_key else "not set")
)
print(
"\n Local Honcho auth (JWT signed with the server's "
"AUTH_JWT_SECRET)."
)
print(
" Leave blank if your server runs with AUTH_USE_AUTH=false. "
f"Current: {masked}"
)
new_local_key = _prompt(
"Local JWT / bearer token (blank to skip / keep current)",
secret=True,
)
if new_local_key:
hermes_host["apiKey"] = new_local_key
elif current_host_key:
print(" Keeping existing local JWT.")
else:
print("\n No API key set. Local no-auth ready.")
# Surface the top-level key situation for transparency.
top_key = cfg.get("apiKey", "")
if top_key:
print(
"\n Top-level API key present in config (kept for "
"cloud/hybrid use)."
)
print(
" Local connections will skip auth automatically "
"until a local JWT is set above."
)
else:
print("\n No local JWT set. Local no-auth ready.")
else:
# --- Cloud: set default base URL, require API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default
+36 -4
View File
@@ -32,6 +32,24 @@ logger = logging.getLogger(__name__)
HOST = "hermes"
def profile_host_key(profile: str | None) -> str:
"""Return the safe Honcho host key for a Hermes profile."""
if not profile or profile in {"default", "custom"}:
return HOST
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
return f"{HOST}_{sanitized or 'profile'}"
def _host_block(raw: dict, host: str) -> dict:
"""Return host config, accepting legacy dot-form profile host keys."""
hosts = raw.get("hosts") or {}
block = hosts.get(host, {})
if block or not host.startswith(f"{HOST}_"):
return block
legacy = f"{HOST}.{host[len(HOST) + 1:]}"
return hosts.get(legacy, {})
def resolve_active_host() -> str:
"""Derive the Honcho host key from the active Hermes profile.
@@ -47,8 +65,7 @@ def resolve_active_host() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name()
if profile and profile not in {"default", "custom"}:
return f"{HOST}.{profile}"
return profile_host_key(profile)
except Exception:
pass
return HOST
@@ -406,7 +423,7 @@ class HonchoClientConfig:
logger.warning("Failed to read %s: %s, falling back to env", path, e)
return cls.from_env(host=resolved_host)
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
host_block = _host_block(raw, resolved_host)
# A hosts.hermes block or explicit enabled flag means the user
# intentionally configured Honcho for this host.
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
@@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
or "::1" in resolved_base_url
)
if _is_local:
# Check if the host block has its own apiKey (explicit local auth)
# Check if the host block has its own apiKey (explicit local auth).
# Auth-skipping is loopback-only: a stored key is likely a cloud key
# that would break a no-auth local server, so we substitute the SDK's
# required-non-empty placeholder unless the host block opts in.
_raw = config.raw or {}
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
_host_has_key = bool(_host_block.get("apiKey"))
@@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
else:
effective_api_key = config.api_key
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
# base_url already ends in a version segment (e.g.
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
# routing concern independent of host, so strip a trailing version segment
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
# SDK then appends its own versioned paths correctly.
if resolved_base_url:
import re as _re
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
kwargs: dict = {
"workspace_id": config.workspace_id,
"api_key": effective_api_key,
+2 -1
View File
@@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def get_config_schema(self):
return [
+2 -1
View File
@@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:
except Exception:
existing = {}
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
def _detect_category(text: str) -> str:
+27 -10
View File
@@ -6093,16 +6093,17 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
``gateway/config.py::load_gateway_config()`` before this migration.
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
inside the adapter to read from ``PlatformConfig.extra`` instead, this
hook keeps the existing env-driven model and merely owns the
YAMLenv translation here, next to the adapter that consumes it.
throughout the connect / handle code paths (``DISCORD_ALLOWED_USERS``,
``DISCORD_REQUIRE_MENTION``, ``DISCORD_FREE_RESPONSE_CHANNELS``,
``DISCORD_AUTO_THREAD``, ``DISCORD_REACTIONS``,
``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``,
``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``,
``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``,
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``).
Rather than rewrite ~50 call sites inside the adapter to read from
``PlatformConfig.extra`` instead, this hook keeps the existing
env-driven model and merely owns the YAMLenv translation here, next to
the adapter that consumes it.
Env vars take precedence over YAML every assignment is guarded by
``not os.getenv(...)`` so explicit env vars survive a config.yaml
@@ -6113,6 +6114,22 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
platforms_cfg = yaml_cfg.get("platforms")
platform_extra_cfg = {}
if isinstance(platforms_cfg, dict):
discord_platform_cfg = platforms_cfg.get("discord")
if isinstance(discord_platform_cfg, dict):
candidate_extra = discord_platform_cfg.get("extra")
if isinstance(candidate_extra, dict):
platform_extra_cfg = candidate_extra
allowed_users_cfg = (
discord_cfg["allow_from"] if "allow_from" in discord_cfg
else platform_extra_cfg.get("allow_from")
)
if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"):
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)
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
+13 -2
View File
@@ -81,6 +81,7 @@ DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
def _build_auth_header(token: str) -> Dict[str, str]:
@@ -311,6 +312,12 @@ class NtfyAdapter(BasePlatformAdapter):
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
return
# Echo-loop prevention: skip messages tagged by this adapter.
tags = event.get("tags") or []
if _ECHO_TAG in tags:
logger.debug("[%s] Skipping own message (echo tag)", self.name)
return
text = (event.get("message") or "").strip()
if not text:
logger.debug("[%s] Empty message body, skipping", self.name)
@@ -387,7 +394,11 @@ class NtfyAdapter(BasePlatformAdapter):
url = f"{self._server}/{publish_topic}"
markdown_enabled = (self.config.extra or {}).get("markdown", False)
headers = {**self._auth_headers(), "Content-Type": "text/plain; charset=utf-8"}
headers = {
**self._auth_headers(),
"Content-Type": "text/plain; charset=utf-8",
"X-Tags": _ECHO_TAG,
}
if markdown_enabled:
headers["X-Markdown"] = "true"
@@ -519,7 +530,7 @@ async def _standalone_send(
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)}
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
if markdown_enabled:
headers["X-Markdown"] = "true"
+108 -22
View File
@@ -17,7 +17,7 @@ Model families (each with t2v + i2v endpoints):
veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video
seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video
kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video
happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video
happy-horse alibaba/happy-horse/text-to-video / alibaba/happy-horse/image-to-video
Selection precedence for the active family:
1. ``model=`` arg from the tool call
@@ -26,14 +26,16 @@ Selection precedence for the active family:
4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs)
5. ``DEFAULT_MODEL``
Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the
gateway downloads and delivers it.
Authentication via ``FAL_KEY`` or the managed Nous gateway. Output is an
HTTPS URL from FAL's CDN; the gateway downloads and delivers it.
"""
from __future__ import annotations
import logging
import os
import threading
import uuid
from typing import Any, Dict, List, Optional, Tuple
from agent.video_gen_provider import (
@@ -104,8 +106,9 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"text_endpoint": "fal-ai/veo3.1",
"image_endpoint": "fal-ai/veo3.1/image-to-video",
"aspect_ratios": ("16:9", "9:16"),
"resolutions": ("720p", "1080p"),
"resolutions": ("720p", "1080p", "4k"),
"durations": (4, 6, 8),
"duration_suffix": "s", # FAL veo3.1 wants "4s" not "4"
"audio": True,
"negative": True,
},
@@ -148,8 +151,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"price": "premium",
"strengths": "Alibaba. New model, sparse public docs — conservative defaults.",
"tier": "premium",
"text_endpoint": "fal-ai/happy-horse/text-to-video",
"image_endpoint": "fal-ai/happy-horse/image-to-video",
"text_endpoint": "alibaba/happy-horse/text-to-video",
"image_endpoint": "alibaba/happy-horse/image-to-video",
# Docs don't expose duration/aspect/resolution — let the endpoint
# apply its own defaults.
"aspect_ratios": None,
@@ -270,7 +273,9 @@ def _build_payload(
clamped = _clamp_duration(family, duration)
if clamped is not None and family.get("durations"):
# FAL exposes duration as a string in the queue API ("8" not 8).
payload["duration"] = str(clamped)
# Some families (e.g. veo3.1) require a unit suffix ("4s" not "4").
suffix = family.get("duration_suffix", "")
payload["duration"] = f"{clamped}{suffix}"
if family.get("audio") and audio is not None:
payload["generate_audio"] = bool(audio)
@@ -302,6 +307,92 @@ def _load_fal_client() -> Any:
return _fal_client
# ---------------------------------------------------------------------------
# Managed FAL gateway (Nous Subscription)
# ---------------------------------------------------------------------------
_managed_fal_video_client: Any = None
_managed_fal_video_client_config: Any = None
_managed_fal_video_client_lock = threading.Lock()
def _resolve_managed_fal_video_gateway():
"""Return managed fal-queue gateway config when the user prefers the gateway
or direct FAL credentials are absent."""
from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway
if fal_key_is_configured() and not prefers_gateway("video_gen"):
return None
from tools.managed_tool_gateway import resolve_managed_tool_gateway
return resolve_managed_tool_gateway("fal-queue")
def _get_managed_fal_video_client(managed_gateway):
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
global _managed_fal_video_client, _managed_fal_video_client_config
from tools.fal_common import _ManagedFalSyncClient
client_config = (
managed_gateway.gateway_origin.rstrip("/"),
managed_gateway.nous_user_token,
)
with _managed_fal_video_client_lock:
if _managed_fal_video_client is not None and _managed_fal_video_client_config == client_config:
return _managed_fal_video_client
_load_fal_client()
_managed_fal_video_client = _ManagedFalSyncClient(
_fal_client,
key=managed_gateway.nous_user_token,
queue_run_origin=managed_gateway.gateway_origin,
)
_managed_fal_video_client_config = client_config
return _managed_fal_video_client
def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]):
"""Submit a FAL video request using direct credentials or the managed queue gateway.
Returns a request handle whose ``.get()`` blocks until the result is ready.
"""
_load_fal_client()
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
managed_gateway = _resolve_managed_fal_video_gateway()
if managed_gateway is None:
return _fal_client.submit(endpoint, arguments=arguments, headers=request_headers)
managed_client = _get_managed_fal_video_client(managed_gateway)
try:
return managed_client.submit(
endpoint,
arguments=arguments,
headers=request_headers,
)
except Exception as exc:
from tools.fal_common import _extract_http_status
status = _extract_http_status(exc)
if status is not None and 400 <= status < 500:
raise ValueError(
f"Nous Subscription gateway rejected endpoint '{endpoint}' "
f"(HTTP {status}). This model may not yet be enabled on "
f"the Nous Portal's FAL proxy. Either:\n"
f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n"
f" • Pick a different model via `hermes tools` → Video Generation."
) from exc
raise
def _check_fal_video_available() -> bool:
"""True if the FAL.ai video backend is reachable (direct key or managed gateway)."""
from tools.tool_backend_helpers import fal_key_is_configured
if fal_key_is_configured():
return True
return _resolve_managed_fal_video_gateway() is not None
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
@@ -323,13 +414,10 @@ class FALVideoGenProvider(VideoGenProvider):
return "FAL"
def is_available(self) -> bool:
if not os.environ.get("FAL_KEY", "").strip():
return False
try:
import fal_client # noqa: F401
except ImportError:
return _check_fal_video_available()
except Exception: # noqa: BLE001 — never break the picker
return False
return True
def list_models(self) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
@@ -394,11 +482,12 @@ class FALVideoGenProvider(VideoGenProvider):
seed: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
if not os.environ.get("FAL_KEY", "").strip():
if not _check_fal_video_available():
return error_response(
error=(
"FAL_KEY not set. Run `hermes tools` → Video Generation "
"→ FAL to configure."
"No FAL backend available. Either set FAL_KEY "
"(run `hermes tools` → Video Generation → FAL to configure) "
"or sign in to Nous (`hermes setup`) for managed gateway access."
),
error_type="auth_required",
provider="fal",
@@ -406,7 +495,7 @@ class FALVideoGenProvider(VideoGenProvider):
)
try:
fal_client = _load_fal_client()
_load_fal_client()
except ImportError:
return error_response(
error="fal_client Python package not installed (pip install fal-client)",
@@ -467,11 +556,8 @@ class FALVideoGenProvider(VideoGenProvider):
)
try:
result = fal_client.subscribe(
endpoint,
arguments=payload,
with_logs=False,
)
handle = _submit_fal_video_request(endpoint, payload)
result = handle.get()
except Exception as exc:
logger.warning(
"FAL video gen failed (family=%s, endpoint=%s): %s",
@@ -511,7 +597,7 @@ class FALVideoGenProvider(VideoGenProvider):
prompt=prompt,
modality=modality_used,
aspect_ratio=aspect_ratio if "aspect_ratio" in payload else "",
duration=int(payload["duration"]) if "duration" in payload else 0,
duration=int("".join(c for c in payload["duration"] if c.isdigit()) or "0") if "duration" in payload else 0,
provider="fal",
extra=extra,
)
+3 -3
View File
@@ -146,16 +146,16 @@ def _get_firecrawl_gateway_url() -> str:
def _is_tool_gateway_ready() -> bool:
"""Return True when gateway URL + Nous Subscriber token are available.
Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway``
Reads ``peek_nous_access_token`` and ``resolve_managed_tool_gateway``
via :mod:`tools.web_tools` rather than direct imports, so unit tests
that ``patch("tools.web_tools._read_nous_access_token", ...)`` see
that ``patch("tools.web_tools._peek_nous_access_token", ...)`` see
their patches honored. The names are re-exported on
:mod:`tools.web_tools` for exactly this reason.
"""
import tools.web_tools as _wt
return _wt.resolve_managed_tool_gateway(
"firecrawl", token_reader=_wt._read_nous_access_token
"firecrawl", token_reader=_wt._peek_nous_access_token
) is not None
+24 -22
View File
@@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
@@ -108,31 +108,31 @@ pty = [
"pywinpty==2.0.15; sys_platform == 'win32'",
]
honcho = ["honcho-ai==2.0.1"]
mcp = ["mcp==1.26.0"]
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
# the ASGI router actually dispatched, so middleware/endpoints that gate on
# `request.url` can be bypassed. We pin a patched Starlette directly in every
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
homeassistant = ["aiohttp==3.13.3"]
sms = ["aiohttp==3.13.3"]
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0"]
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
acp = ["agent-client-protocol==0.9.0"]
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of
# `mistralai` returns 404 on PyPI right now, so any pin we'd write is
# unresolvable, which breaks `uv lock --check` in CI.
#
# To restore once PyPI un-quarantines:
# 1. Verify the new release is clean (read the changelog, check Socket
# advisory page, confirm no malicious code review findings).
# 2. Add back: mistral = ["mistralai==<verified-version>"]
# 3. Re-enable Mistral in:
# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"])
# - hermes_cli/tools_config.py (un-hide from provider picker)
# - hermes_cli/web_server.py (re-add to dashboard STT options)
# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs)
# 4. Run `uv lock` to regenerate transitives.
# 5. Optionally re-add to [all] only after a few days of clean operation.
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
# 2.4.6 release (Mini Shai-Hulud worm); 2.4.6 was removed from PyPI and the
# project is serving clean releases again (2.4.7 2026-05-25, 2.4.8 2026-05-28).
# Like other opt-in TTS/STT backends, this is lazy-installed via
# tools/lazy_deps.py (stt.mistral / tts.mistral) at first use — deliberately
# NOT re-added to [all] so a future quarantined release can't break fresh
# installs (see [all] policy comment below).
mistral = ["mistralai==2.4.8"]
bedrock = ["boto3==1.42.89"]
azure-identity = ["azure-identity==1.25.3"]
termux = [
@@ -175,7 +175,9 @@ youtube = [
"youtube-transcript-api==1.2.4",
]
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
@@ -217,7 +219,7 @@ hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
@@ -237,7 +239,7 @@ plugins = [
]
[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+175 -5
View File
@@ -2106,13 +2106,48 @@ class AIAgent:
pass
return True # safe default: verifier on
@staticmethod
def _format_file_mutation_failure_footer(failed: Dict[str, Dict[str, Any]]) -> str:
# Bare absolute / home / Windows-drive file paths in a footer line.
# Anchors mirror the gateway's ``extract_local_files`` bare-path
# detector so that anything the gateway WOULD auto-attach is wrapped
# in inline-code backticks here first (the extractor skips paths inside
# `code` spans). Defense-in-depth: even if a future error message
# echoes a credential path (config.yaml, .env, auth.json) into the
# user-facing footer, it can never be matched as a deliverable bare
# path and silently uploaded to a messaging channel (#35584).
_FOOTER_PATH_RE = re.compile(
r"(?<![/:\w.`])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.[\w]+",
)
@classmethod
def _neutralize_footer_paths(cls, text: str) -> str:
"""Wrap bare file paths in backticks so they aren't auto-delivered.
The gateway's ``extract_local_files`` scans response text for bare
absolute/home paths ending in a deliverable extension and uploads
any that exist on disk as native attachments but it explicitly
skips paths inside inline-code (`` `...` ``) spans. Backticking
every path the footer renders defeats that auto-detection while
keeping the path fully human-readable. Paths already wrapped in a
backtick (the negative lookbehind excludes a preceding `` ` ``) are
left untouched so we never double-wrap.
"""
if not text:
return text
return cls._FOOTER_PATH_RE.sub(lambda m: f"`{m.group(0)}`", text)
@classmethod
def _format_file_mutation_failure_footer(cls, failed: Dict[str, Dict[str, Any]]) -> str:
"""Render the per-turn failed-mutation dict as a user-facing footer.
Displays up to 10 paths with their first error preview, then a
count of any additional failures. Returns an empty string when
the dict is empty so callers can concatenate unconditionally.
Every file path that reaches the user-facing text both the bullet
path and any path echoed inside the tool's error preview — is
backtick-wrapped via ``_neutralize_footer_paths`` so the gateway's
bare-path media extractor can never auto-attach a protected file
(e.g. ``~/.hermes/config.yaml``) to a messaging channel (#35584).
"""
if not failed:
return ""
@@ -2129,14 +2164,137 @@ class AIAgent:
preview = (info.get("error_preview") or "").strip()
tool = info.get("tool") or "patch"
if preview:
lines.append(f"{path} — [{tool}] {preview}")
lines.append(f"`{path}` — [{tool}] {preview}")
else:
lines.append(f"{path} — [{tool}] failed")
lines.append(f"`{path}` — [{tool}] failed")
shown += 1
remaining = len(failed) - shown
if remaining > 0:
lines.append(f" • … and {remaining} more")
return "\n".join(lines)
# Neutralize any path the preview text echoed (the bullet path is
# already backticked above; the lookbehind keeps it from being
# double-wrapped).
return cls._neutralize_footer_paths("\n".join(lines))
def _turn_completion_explainer_enabled(self) -> bool:
"""Check whether the end-of-turn completion explainer footer is on.
Config path: ``display.turn_completion_explainer`` (bool, default
True). ``HERMES_TURN_COMPLETION_EXPLAINER`` env var overrides
config. Exposed as a method so tests can patch a single seam,
mirroring ``_file_mutation_verifier_enabled``.
"""
try:
import os as _os
env = _os.environ.get("HERMES_TURN_COMPLETION_EXPLAINER")
if env is not None:
return env.strip().lower() not in {"0", "false", "no", "off"}
# Read from the persisted config.yaml so gateway and CLI share
# the same setting. Import lazily to avoid a startup-time cycle.
try:
from hermes_cli.config import load_config as _load_config
_cfg = _load_config() or {}
except Exception:
_cfg = {}
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
if isinstance(_display, dict) and "turn_completion_explainer" in _display:
return bool(_display.get("turn_completion_explainer"))
except Exception:
pass
return True # safe default: explainer on
@staticmethod
def _format_turn_completion_explanation(turn_exit_reason: str) -> str:
"""Render a user-facing explanation for an abnormal turn ending.
Maps the internal ``turn_exit_reason`` to a short, actionable
message so a turn that produced no usable assistant reply (empty
content after retries, a partial/truncated stream, a still-pending
tool result, or an iteration/budget limit) is never silent from
the UI's perspective — the symptom users report in #34452.
Returns an empty string for reasons that are NOT abnormal (e.g.
a normal ``text_response(...)`` exit), so callers can concatenate
or substitute unconditionally without warning on healthy turns
like a terse ``Done.``.
"""
if not turn_exit_reason:
return ""
reason = str(turn_exit_reason)
# Normal completion — stay quiet. ``text_response(...)`` is the
# healthy terminal; anything that produced a real reply is fine.
if reason.startswith("text_response"):
return ""
prefix = "⚠️ No reply: "
if reason == "empty_response_exhausted":
return (
prefix
+ "the model returned empty content after retries and any "
"fallback providers. Try `continue`, switch model/provider, "
"or inspect the tool output above."
)
if reason == "all_retries_exhausted_no_response":
return (
prefix
+ "all API retries were exhausted before a response was "
"produced (provider errors / rate limits). Try `continue` "
"or switch provider."
)
if reason == "partial_stream_recovery":
return (
prefix
+ "streaming stopped early and only a partial response was "
"recovered. Send `continue` to resume from where it stopped."
)
if reason == "fallback_prior_turn_content":
return (
prefix
+ "no new content was produced this turn; showing recovered "
"prior context. Send `continue` to retry."
)
if reason == "interrupted_during_api_call":
return (
prefix
+ "the request was interrupted mid-call before a reply was "
"received. Send `continue` to retry."
)
if reason == "budget_exhausted":
return (
prefix
+ "the per-turn iteration/cost budget was exhausted before a "
"final answer. Send `continue` to keep going."
)
if reason == "ollama_runtime_context_too_small":
return (
prefix
+ "the local model's context window was too small to finish. "
"Increase the context size or use a larger model."
)
if reason.startswith("max_iterations_reached"):
return (
prefix
+ "the maximum tool-iteration limit was reached before a "
"final answer. Send `continue` to keep going, or raise "
"`max_iterations`."
)
if reason.startswith("error_near_max_iterations"):
return (
prefix
+ "an error occurred near the iteration limit before a final "
"answer. Check the tool output above, then send `continue`."
)
if reason == "pending_tool_result":
return (
prefix
+ "the turn stopped while a tool result was still pending and "
"the model produced no follow-up text. Send `continue` to "
"let it summarize."
)
# Unknown/diagnostic-only reasons (e.g. "unknown", guardrail_halt
# which already surfaces its own message) — don't second-guess.
return ""
def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: int) -> None:
"""Forwarder — see ``agent.agent_runtime_helpers.apply_pending_steer_to_tool_results``."""
@@ -3430,6 +3588,18 @@ class AIAgent:
from agent.chat_completion_helpers import try_activate_fallback
return try_activate_fallback(self, reason)
def _has_pending_fallback(self) -> bool:
"""Whether a fallback provider is actually available to switch to.
Used to gate user-facing "trying fallback..." status so we don't
announce a fallback that will never be attempted (the user has no
fallback chain configured). Mirrors the early-return guard in
``try_activate_fallback`` (#35314, #17446).
"""
chain = getattr(self, "_fallback_chain", None) or []
index = getattr(self, "_fallback_index", 0)
return index < len(chain)
# ── Per-turn primary restoration ─────────────────────────────────────
def _restore_primary_runtime(self) -> bool:
+100
View File
@@ -473,6 +473,7 @@ check_python() {
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
ensure_fts5
return 0
fi
@@ -482,6 +483,7 @@ check_python() {
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
ensure_fts5
else
log_error "Failed to install Python $PYTHON_VERSION"
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
@@ -489,6 +491,104 @@ check_python() {
fi
}
# Probe whether $1 (a python executable) links a SQLite with the FTS5
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
# virtual tables for full-text session search; a SQLite without FTS5 makes
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
_python_has_fts5() {
"$1" - <<'PY' 2>/dev/null
import sqlite3, sys
try:
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
except Exception:
sys.exit(1)
PY
}
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
# Returns 0 if the resulting interpreter ships FTS5.
_reinstall_python_with_fts5() {
local uv_bin="$1"
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
}
_warn_no_fts5() {
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
# Install proceeds — Hermes degrades gracefully and disables only full-text
# session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
}
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
# but WHICH builds a given uv can install is baked into the uv binary's
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
#
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
# an already-current uv)
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
# this is what fixes a stale standalone uv
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
# fresh standalone uv via the official installer into a temp dir and use
# THAT to reinstall — this fixes package-manager-managed stale uv
#
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
# stale interpreter in place and the installer's later `uv python find` resolves
# to it. Keeps session search working without bundling a second SQLite or asking
# the user to do anything.
ensure_fts5() {
[ -n "${PYTHON_PATH:-}" ] || return 0
if _python_has_fts5 "$PYTHON_PATH"; then
return 0
fi
# Termux / non-uv installs have nothing to escalate.
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
# Python builds. Try to update uv in place.
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
if "$UV_CMD" self update >/dev/null 2>&1; then
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
fi
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
# a fresh standalone uv into a temp dir and use it just for the reinstall.
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
local _tmp_uv_dir _fresh_uv
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
mkdir -p "$_tmp_uv_dir"
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
_fresh_uv="$_tmp_uv_dir/uv"
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
rm -rf "$_tmp_uv_dir"
return 0
fi
fi
rm -rf "$_tmp_uv_dir"
_warn_no_fts5
}
check_git() {
log_info "Checking Git..."
+28
View File
@@ -45,6 +45,18 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"zhipengli@thebrainly.ai": "a1245582339",
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
"drpelagik@gmail.com": "SeaXen",
"lengr@users.noreply.github.com": "LengR",
"metalclaudbot@gmail.com": "HashClawAI",
"tonybear55665566@gmail.com": "TonyPepeBear",
"kaspersniels@gmail.com": "nielskaspers",
"kurobaryo@gmail.com": "kurobaryo",
"155192176+alelpoan@users.noreply.github.com": "alelpoan",
"aman@abacus.ai": "Aman113114-IITD",
"octavio.turra@gmail.com": "octavioturra",
"524706+Twanislas@users.noreply.github.com": "Twanislas",
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
"kchuang1015@users.noreply.github.com": "kchuang1015",
"45688690+fujinice@users.noreply.github.com": "fujinice",
@@ -59,6 +71,7 @@ AUTHOR_MAP = {
"wangpuv@hotmail.com": "wangpuv",
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
"wuxuebin1993@gmail.com": "victorGPT",
"wei.chen.coder@gmail.com": "wenchengxucool",
"frowte3k@gmail.com": "Frowtek",
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
"17778+kweiner@users.noreply.github.com": "kweiner",
@@ -67,6 +80,7 @@ AUTHOR_MAP = {
"interstellar.consulting@gmail.com": "Interstellar-code",
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
"tillfalko@gmail.com": "tillfalko",
"hi@fesalfayed.com": "fesalfayed",
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"kenyon1977@gmail.com": "kenyonxu",
@@ -84,6 +98,7 @@ AUTHOR_MAP = {
"steve@steveonjava.com": "steveonjava",
"steveonjava@gmail.com": "steveonjava",
"squiddy@2rook.ai": "MoonRay305",
"annguyenNous@users.noreply.github.com": "annguyenNous",
"32201324+simpolism@users.noreply.github.com": "simpolism",
"simpolism@gmail.com": "simpolism",
"jake@nousresearch.com": "simpolism",
@@ -112,6 +127,7 @@ AUTHOR_MAP = {
"david@memorilabs.ai": "devwdave",
"dave@devwdave.com": "devwdave",
"1920071390@campus.ouj.ac.jp": "zapabob",
"zapabob@users.noreply.github.com": "zapabob",
"gaia@gaia.local": "jfuenmayor",
"jiahuigu@users.noreply.github.com": "Jiahui-Gu",
"openhands@all-hands.dev": "YLChen-007",
@@ -120,6 +136,8 @@ AUTHOR_MAP = {
"32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn",
"32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
"liuhao1024@users.noreply.github.com": "liuhao1024",
"annguyenNous@users.noreply.github.com": "annguyenNous",
"285874597+annguyenNous@users.noreply.github.com": "annguyenNous",
"kylekahraman@users.noreply.github.com": "kylekahraman",
"130975919+kylekahraman@users.noreply.github.com": "kylekahraman",
"seppe@fushia.be": "seppegadeyne",
@@ -208,6 +226,7 @@ AUTHOR_MAP = {
"264291321+v1b3coder@users.noreply.github.com": "v1b3coder",
"silverchris@foxmail.com": "ming1523",
"maksesipov@gmail.com": "Qwinty",
"byquenox@gmail.com": "Que0x",
"denisamania@gmail.com": "CalmProton",
"308068+mbac@users.noreply.github.com": "mbac",
"nicoechaniz@altermundi.net": "nicoechaniz",
@@ -514,6 +533,8 @@ AUTHOR_MAP = {
"barnacleboy.jezzahehn@agentmail.to": "JezzaHehn",
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
"123342691+banditburai@users.noreply.github.com": "banditburai",
"9063726+Kyzcreig@users.noreply.github.com": "Kyzcreig",
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
@@ -635,7 +656,10 @@ AUTHOR_MAP = {
"alexazzjjtt@163.com": "alexzhu0",
"pub_forgreatagent@antgroup.com": "AntAISecurityLab",
"252620095+briandevans@users.noreply.github.com": "briandevans",
"incharge.automation@gmail.com": "inchargeautomation-lab",
"danielrpike9@gmail.com": "Bartok9",
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
"laflamme@illinoisalumni.org": "briancl2",
"skozyuk@cruxexperts.com": "CruxExperts",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
@@ -1188,6 +1212,7 @@ AUTHOR_MAP = {
"86501179+1RB@users.noreply.github.com": "1RB", # PR #25462 salvage (discord forwarded messages)
"44045943+ayushere@users.noreply.github.com": "ayushere", # PR #25342 salvage (memory teardown leak)
"15791290+domtriola@users.noreply.github.com": "domtriola", # PR #25424 salvage (docs tirith link)
"tuancookiez@gmail.com": "tuancookiez-hub", # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864)
"284216128+ephron-ren@users.noreply.github.com": "ephron-ren", # PR #25358 salvage (MiMo reasoning echo-back)
"96843562+freqyfreqy@users.noreply.github.com": "freqyfreqy", # PR #25423 salvage (docs LSP worktree -> repo)
"54306477+fu576@users.noreply.github.com": "fu576", # PR #25369 salvage (api_mode not inherited cross-provider)
@@ -1382,6 +1407,9 @@ AUTHOR_MAP = {
"peter.yuqin@gmail.com": "WuKongAI-CMU", # PR #10082 (reject symlinked audio inputs)
"sunil.nitie@gmail.com": "Sunil123135", # PR #31031 (Windows Docker Desktop compose)
"weichangyuwcy@gmail.com": "ChyuWei", # PR #30987 (TUI TTS env var on voice off)
# batch salvage PR #35758 (perf micro-fixes)
"116212274+amathxbt@users.noreply.github.com": "amathxbt", # PR #22155 (cache tool_output_limits)
"takis312@hotmail.com": "ErnestHysa", # PRs #32636/#32708 (MCP asyncio.sleep + O(n^2) watcher drain)
}
@@ -446,15 +446,15 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
### Secret redaction in tool output
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
Secret redaction is **on by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) is scanned for strings that look like API keys, tokens, and secrets before it enters the conversation context and logs. Leave it enabled for normal use:
```bash
hermes config set security.redact_secrets true # enable globally
hermes config set security.redact_secrets true # keep enabled globally
```
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to change it in config from a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
Disable again with:
Disable only when you deliberately need raw credential-like strings for debugging or redactor development:
```bash
hermes config set security.redact_secrets false
```
@@ -178,6 +178,30 @@ Tell them what you created in plain prose, naming the actual profiles you used:
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
## Goal-mode cards (persistent workers)
By default a dispatched worker gets **one shot** at its card: it does its work, calls `kanban_complete`/`kanban_block`, and exits. For open-ended cards where one turn rarely finishes the job, pass `goal_mode=True` to wrap that worker in a Ralph-style goal loop — the same engine behind the `/goal` slash command:
```python
kanban_create(
title="Translate the full docs site to French",
body="Acceptance: every page translated, no English left, links intact.",
assignee="<translator-profile>",
goal_mode=True, # judge re-checks the card after each turn
goal_max_turns=15, # optional budget (default 20)
)["task_id"]
```
How it behaves:
- After each worker turn, an auxiliary judge evaluates the worker's response against the card's **title + body** (treated as the acceptance criteria).
- Not done + budget remains → the worker keeps going **in the same session** (full context retained — not a fresh respawn).
- Worker calls `kanban_complete`/`kanban_block` itself → loop stops, normal lifecycle.
- Budget exhausted without completion → the card is **blocked** for human review (sticky), never a silent exit.
When to use it: long, multi-step, or "keep going until X is true" cards. When NOT to: cheap one-shot cards (translation of a single string, a quick lookup) — the judge overhead isn't worth it, and the dispatcher's existing retry/circuit-breaker already handles transient worker failures.
Write the body as **explicit acceptance criteria** — the judge is only as good as the goal text. "Translate the README" is weaker than "Translate every section of the README to French; no English sentences remain."
## Recovering stuck workers
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
@@ -129,7 +129,11 @@ def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None
def _headers_dict(msg: dict) -> dict[str, str]:
return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
return {
h["name"].lower(): h["value"]
for h in msg.get("payload", {}).get("headers", [])
if h.get("name")
}
def _extract_message_body(msg: dict) -> str:
@@ -230,10 +234,10 @@ def gmail_search(args):
{
"id": msg["id"],
"threadId": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"from": headers.get("from", ""),
"to": headers.get("to", ""),
"subject": headers.get("subject", ""),
"date": headers.get("date", ""),
"snippet": msg.get("snippet", ""),
"labels": msg.get("labelIds", []),
}
@@ -260,10 +264,10 @@ def gmail_search(args):
output.append({
"id": msg["id"],
"threadId": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"from": headers.get("from", ""),
"to": headers.get("to", ""),
"subject": headers.get("subject", ""),
"date": headers.get("date", ""),
"snippet": msg.get("snippet", ""),
"labels": msg.get("labelIds", []),
})
@@ -281,10 +285,10 @@ def gmail_get(args):
result = {
"id": msg["id"],
"threadId": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"from": headers.get("from", ""),
"to": headers.get("to", ""),
"subject": headers.get("subject", ""),
"date": headers.get("date", ""),
"labels": msg.get("labelIds", []),
"body": _extract_message_body(msg),
}
@@ -300,10 +304,10 @@ def gmail_get(args):
result = {
"id": msg["id"],
"threadId": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"from": headers.get("from", ""),
"to": headers.get("to", ""),
"subject": headers.get("subject", ""),
"date": headers.get("date", ""),
"labels": msg.get("labelIds", []),
"body": _extract_message_body(msg),
}
@@ -314,12 +318,12 @@ def gmail_get(args):
def gmail_send(args):
if _gws_binary():
message = MIMEText(args.body, "html" if args.html else "plain")
message["to"] = args.to
message["subject"] = args.subject
message["To"] = args.to
message["Subject"] = args.subject
if args.cc:
message["cc"] = args.cc
message["Cc"] = args.cc
if args.from_header:
message["from"] = args.from_header
message["From"] = args.from_header
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
body = {"raw": raw}
@@ -336,12 +340,12 @@ def gmail_send(args):
service = build_service("gmail", "v1")
message = MIMEText(args.body, "html" if args.html else "plain")
message["to"] = args.to
message["subject"] = args.subject
message["To"] = args.to
message["Subject"] = args.subject
if args.cc:
message["cc"] = args.cc
message["Cc"] = args.cc
if args.from_header:
message["from"] = args.from_header
message["From"] = args.from_header
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
body = {"raw": raw}
@@ -367,18 +371,18 @@ def gmail_reply(args):
)
headers = _headers_dict(original)
subject = headers.get("Subject", "")
subject = headers.get("subject", "")
if not subject.startswith("Re:"):
subject = f"Re: {subject}"
message = MIMEText(args.body)
message["to"] = headers.get("From", "")
message["subject"] = subject
message["To"] = headers.get("from", "")
message["Subject"] = subject
if args.from_header:
message["from"] = args.from_header
if headers.get("Message-ID"):
message["In-Reply-To"] = headers["Message-ID"]
message["References"] = headers["Message-ID"]
message["From"] = args.from_header
if headers.get("message-id"):
message["In-Reply-To"] = headers["message-id"]
message["References"] = headers["message-id"]
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
result = _run_gws(
@@ -396,18 +400,18 @@ def gmail_reply(args):
).execute()
headers = _headers_dict(original)
subject = headers.get("Subject", "")
subject = headers.get("subject", "")
if not subject.startswith("Re:"):
subject = f"Re: {subject}"
message = MIMEText(args.body)
message["to"] = headers.get("From", "")
message["subject"] = subject
message["To"] = headers.get("from", "")
message["Subject"] = subject
if args.from_header:
message["from"] = args.from_header
if headers.get("Message-ID"):
message["In-Reply-To"] = headers["Message-ID"]
message["References"] = headers["Message-ID"]
message["From"] = args.from_header
if headers.get("message-id"):
message["In-Reply-To"] = headers["message-id"]
message["References"] = headers["message-id"]
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
body = {"raw": raw, "threadId": original["threadId"]}
@@ -46,7 +46,7 @@
% \author{Author 1 \\ Address line \\ ... \\ Address line
% \And ... \And
% Author n \\ Address line \\ ... \\ Address line}
% To start a seperate ``row'' of authors use \AND, as in
% To start a separate ``row'' of authors use \AND, as in
% \author{Author 1 \\ Address line \\ ... \\ Address line
% \AND
% Author 2 \\ Address line \\ ... \\ Address line \And
@@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
assert install_targets == ["pyright"]
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
"""Installed Windows shims should satisfy later status/probe calls."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
wrapper.write_text("@echo off\n")
wrapper.chmod(0o755)
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
assert install_mod.detect_status("pyright") == "installed"
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
"""pip console scripts can land in Scripts/ on native Windows."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
def fake_run(cmd, **kwargs):
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
launcher = scripts_dir / "fake-language-server.exe"
launcher.write_text("launcher\n")
launcher.chmod(0o755)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
assert resolved is not None
assert resolved.endswith("fake-language-server.exe")
assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
# ---------------------------------------------------------------------------
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
# ---------------------------------------------------------------------------
+73
View File
@@ -1827,6 +1827,79 @@ class TestThinkingBlockSignatureManagement:
assert len(last_thinking) == 1
assert last_thinking[0]["signature"] == "sig_3"
def test_orphan_stripped_tool_use_demotes_dead_signed_thinking(self):
"""Regression: extended-thinking + interrupted parallel tool batch.
An assistant turn with a signed thinking block fires several parallel
tool_use blocks, but the batch is interrupted before every tool_result
comes back. On replay, the orphaned tool_use is stripped which mutates
the turn and invalidates the thinking-block signature (it was computed
against the original, un-stripped content). Anthropic then rejects the
turn with HTTP 400 "thinking blocks in the latest assistant message
cannot be modified", a non-retryable error that crash-loops the gateway.
The signed thinking block on the mutated latest turn must be demoted to
a plain text block so the turn replays cleanly.
"""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_kept", "function": {"name": "tool_a", "arguments": "{}"}},
{"id": "tc_orphan", "function": {"name": "tool_b", "arguments": "{}"}},
],
"reasoning_details": [
{"type": "thinking", "thinking": "Plan: call A and B.", "signature": "sig_dead"},
],
},
# Only one of the two parallel tool_use blocks got a result back.
{"role": "tool", "tool_call_id": "tc_kept", "content": "result A"},
]
_, result = convert_messages_to_anthropic(messages)
assistant = next(m for m in result if m["role"] == "assistant")
blocks = assistant["content"]
# No signed thinking block survives — the signature is dead.
assert not any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in blocks
)
# The reasoning text is preserved as a text block (not silently lost).
text_contents = [b.get("text", "") for b in blocks if b.get("type") == "text"]
assert "Plan: call A and B." in text_contents
# The orphaned tool_use is gone; the answered one survives.
tool_use_ids = [b.get("id") for b in blocks if b.get("type") == "tool_use"]
assert tool_use_ids == ["tc_kept"]
# Internal bookkeeping flag must never leak into the API payload.
assert "_thinking_signature_invalidated" not in assistant
def test_signed_thinking_preserved_when_no_tool_use_stripped(self):
"""Control: an intact latest turn keeps its signed thinking verbatim.
This guards against the orphan-strip fix over-firing when no tool_use
is removed, the signature is still valid and must be replayed as-is.
"""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "tool_a", "arguments": "{}"}},
],
"reasoning_details": [
{"type": "thinking", "thinking": "Valid plan.", "signature": "sig_live"},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result A"},
]
_, result = convert_messages_to_anthropic(messages)
assistant = next(m for m in result if m["role"] == "assistant")
thinking = [b for b in assistant["content"] if b.get("type") == "thinking"]
assert len(thinking) == 1
assert thinking[0]["signature"] == "sig_live"
assert "_thinking_signature_invalidated" not in assistant
# ---------------------------------------------------------------------------
# Tool choice

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