Compare commits

...
Author SHA1 Message Date
teknium1 8825ad20c1 fix(nix): drop --replace + default Restart=on-failure for gateway service
Extends the systemd/launchd --replace removal to the NixOS module: flip the
host-unit Restart default from always to on-failure, and drop --replace from the
container-mode (OCI) create command. Keeps every Hermes-generated service surface
consistent — none default to takeover mode under a service manager.
2026-05-29 19:37:22 -07:00
LeonSGP43 152207c0cc fix(gateway): avoid restart loops on replace 2026-05-29 19:35:18 -07:00
LeonSGP43 e643c79c2c fix(gateway): stop default replace in service runs 2026-05-29 19:35:14 -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
152 changed files with 6639 additions and 558 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`.
+16 -17
View File
@@ -4725,24 +4725,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:
+46
View File
@@ -518,6 +518,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 +619,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 +656,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.
+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:
+7 -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:,}",
)
+92 -6
View File
@@ -600,18 +600,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 +678,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
@@ -3862,6 +3894,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 +4480,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
+1 -1
View File
@@ -331,7 +331,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.
+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.
+170 -15
View File
@@ -3248,6 +3248,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
@@ -6693,10 +6699,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 +6827,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 +8388,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 +9947,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 +9974,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 +9996,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 +10023,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 +10040,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 +12945,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 +12963,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 +14103,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 +14116,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 +14667,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:
+16
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,6 +768,11 @@ 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"),
+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]]:
"""
+80 -23
View File
@@ -1131,6 +1131,75 @@ 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.
MEDIA_TAG_CLEANUP_RE = re.compile(
r'''[`"']?MEDIA:\s*'''
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
r'''(?:~/|/)\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 +2611,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,24 +2660,7 @@ 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)
@@ -3729,7 +3781,12 @@ 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))
+25 -5
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
@@ -1408,6 +1409,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,7 +1448,7 @@ 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]] = {}
@@ -2835,11 +2838,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:
+92 -11
View File
@@ -11743,9 +11743,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 +12449,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 +12463,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 +12492,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 +12528,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
@@ -17478,13 +17516,33 @@ 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:
@@ -18442,7 +18500,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 +18512,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:
+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():
+24 -2
View File
@@ -221,7 +221,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 +233,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 +254,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
@@ -691,6 +698,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",
+7
View File
@@ -1202,6 +1202,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
+48 -13
View File
@@ -207,9 +207,11 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
SIGUSR1 is wired in gateway/run.py to ``request_restart(via_service=True)``
which drains in-flight agent runs (up to ``agent.restart_drain_timeout``
seconds), then exits with code 75. Both systemd (``Restart=always``
+ ``RestartForceExitStatus=75``) and launchd (``KeepAlive.SuccessfulExit
= false``) relaunch the process after the graceful exit.
seconds), then exits with code 75. Systemd units generated by Hermes use
``Restart=on-failure`` together with ``RestartForceExitStatus=75`` so the
service is relaunched after the graceful exit without reviving clean
``--replace`` takeovers. launchd still uses ``KeepAlive.SuccessfulExit =
false`` for the same relaunch behavior.
This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``,
which SIGKILL in-flight agents after a short timeout.
@@ -565,7 +567,7 @@ def _gateway_run_args_for_profile(profile: str) -> list[str]:
args = [get_python_path(), "-m", "hermes_cli.main"]
if profile != "default":
args.extend(["--profile", profile])
args.extend(["gateway", "run", "--replace"])
args.extend(["gateway", "run"])
return args
@@ -2161,9 +2163,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 +2222,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))
@@ -2209,7 +2242,7 @@ StartLimitIntervalSec=0
Type=simple
User={username}
Group={group_name}
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
WorkingDirectory={working_dir}
Environment="HOME={home_dir}"
Environment="USER={username}"
@@ -2217,7 +2250,7 @@ Environment="LOGNAME={username}"
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
Restart=on-failure
RestartSec=5
RestartMaxDelaySec=300
RestartSteps=5
@@ -2247,12 +2280,12 @@ StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
Restart=on-failure
RestartSec=5
RestartMaxDelaySec=300
RestartSteps=5
@@ -2804,7 +2837,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)
@@ -2841,7 +2877,6 @@ def generate_launchd_plist() -> str:
prog_args.extend([
"<string>gateway</string>",
"<string>run</string>",
"<string>--replace</string>",
])
prog_args_xml = "\n ".join(prog_args)
@@ -3236,7 +3271,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
print()
# Exit with code 1 if gateway fails to connect any platform,
# so systemd Restart=always will retry on transient errors
# so systemd Restart=on-failure will retry on transient errors
verbosity = None if quiet else verbose
# ── Exit-path diagnostics ────────────────────────────────────────────
+59 -7
View File
@@ -9125,12 +9125,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)
@@ -9467,6 +9468,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 +10582,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.")
@@ -13390,6 +13409,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 +13586,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()
+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:
+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"],
+2 -2
View File
@@ -49,7 +49,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("xiaomi/mimo-v2.5-pro", ""),
("tencent/hy3-preview", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
("google/gemini-3.5-flash", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("qwen/qwen3.6-35b-a3b", ""),
@@ -156,7 +156,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"xiaomi/mimo-v2.5-pro",
"tencent/hy3-preview",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
"google/gemini-3.5-flash",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.6-35b-a3b",
+48 -3
View File
@@ -71,12 +71,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 +259,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 +294,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 +303,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 +319,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 +330,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 +369,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 +467,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",
@@ -561,6 +589,9 @@ def apply_nous_managed_defaults(
if "image_gen" in selected_toolsets and not fal_key_is_configured():
changed.add("image_gen")
if "video_gen" in selected_toolsets and not fal_key_is_configured():
changed.add("video_gen")
return changed
@@ -571,6 +602,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 +610,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 +619,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 +635,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 +681,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 +750,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
+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
+51 -23
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
+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=(
+19 -16
View File
@@ -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")
+47 -16
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):
@@ -2153,7 +2175,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 +2194,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 +2536,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)
@@ -2597,7 +2628,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 +2707,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")
@@ -2957,7 +2988,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 +3028,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...")
+1 -3
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",
+68 -1
View File
@@ -3251,7 +3251,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 +3316,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 +3339,7 @@ class SessionDB:
except Exception:
pass
self._conn.execute("VACUUM")
return optimized
def maybe_auto_prune_and_vacuum(
self,
+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"
+3 -3
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 ───────────────────────────────────────────────
@@ -535,7 +535,7 @@
restart = mkOption {
type = types.str;
default = "always";
default = "on-failure";
description = "systemd Restart= policy.";
};
@@ -974,7 +974,7 @@
--env MESSAGING_CWD=${containerWorkDir} \
${lib.concatStringsSep " " cfg.container.extraOptions} \
${cfg.container.image} \
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
${containerDataDir}/current-package/bin/hermes gateway run ${lib.concatStringsSep " " cfg.extraArgs}
echo "${containerIdentity}" > ${identityFile}
fi
-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.
@@ -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/`
+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:
+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,
)
+11 -18
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", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"]
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"]
@@ -117,22 +117,15 @@ sms = ["aiohttp==3.13.3"]
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0"]
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 = [
@@ -237,7 +230,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"]
+120
View File
@@ -2138,6 +2138,126 @@ class AIAgent:
lines.append(f" • … and {remaining} more")
return "\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``."""
from agent.agent_runtime_helpers import apply_pending_steer_to_tool_results
+15
View File
@@ -45,6 +45,14 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"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",
@@ -84,6 +92,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 +121,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 +130,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",
@@ -514,6 +526,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",
@@ -636,6 +650,7 @@ AUTHOR_MAP = {
"pub_forgreatagent@antgroup.com": "AntAISecurityLab",
"252620095+briandevans@users.noreply.github.com": "briandevans",
"danielrpike9@gmail.com": "Bartok9",
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
"skozyuk@cruxexperts.com": "CruxExperts",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
@@ -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
```
@@ -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
+56
View File
@@ -88,6 +88,62 @@ class TestAuxiliaryMaxTokensParam:
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
class TestBuildCallKwargsMaxTokens:
"""_build_call_kwargs should not cap output by default (#34530).
Most chat-completions providers treat an omitted max_tokens as "use the
model max", which is what we want for auxiliary tasks. An explicit cap only
risks truncation or a wire-format 400 (GitHub Copilot / GPT-5 reject
max_tokens; ZAI vision rejects it entirely). The Anthropic Messages wire is
the one exception max_tokens is a mandatory field there.
"""
@pytest.mark.parametrize(
"provider,model,base_url",
[
("copilot", "gpt-5.4", "https://api.githubcopilot.com"),
("copilot", "gpt-5.5", "https://api.githubcopilot.com"),
("custom", "gpt-5", "https://api.openai.com/v1"),
("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1"),
("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"),
("custom", "qwen", "http://localhost:8080/v1"),
("zai", "glm-4v-flash", "https://open.bigmodel.cn/api/paas/v4"),
],
)
def test_omits_max_tokens_for_openai_compatible(self, provider, model, base_url):
from agent.auxiliary_client import _build_call_kwargs
kwargs = _build_call_kwargs(
provider=provider,
model=model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1234,
base_url=base_url,
)
assert "max_tokens" not in kwargs
assert "max_completion_tokens" not in kwargs
@pytest.mark.parametrize(
"provider,model,base_url",
[
("minimax", "minimax-m2", "https://api.minimax.io/v1"),
("custom", "claude", "https://proxy.example.com/anthropic/v1"),
],
)
def test_keeps_max_tokens_on_anthropic_wire(self, provider, model, base_url):
from agent.auxiliary_client import _build_call_kwargs
kwargs = _build_call_kwargs(
provider=provider,
model=model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1234,
base_url=base_url,
)
assert kwargs["max_tokens"] == 1234
assert "max_completion_tokens" not in kwargs
class TestNormalizeAuxProvider:
def test_maps_github_copilot_aliases(self):
assert _normalize_aux_provider("github") == "copilot"
+29
View File
@@ -41,6 +41,8 @@ class TestShouldCompress:
class TestUpdateFromResponse:
def test_updates_fields(self, compressor):
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor.update_from_response({
"prompt_tokens": 5000,
"completion_tokens": 1000,
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
})
assert compressor.last_prompt_tokens == 5000
assert compressor.last_completion_tokens == 1000
assert compressor.last_real_prompt_tokens == 5000
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
assert compressor.awaiting_real_usage_after_compression is False
def test_missing_fields_default_zero(self, compressor):
compressor.update_from_response({})
assert compressor.last_prompt_tokens == 0
class TestPreflightDeferral:
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
def test_does_not_defer_without_recent_real_usage(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 0
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
class TestCompress:
def _make_messages(self, n):
+2
View File
@@ -440,6 +440,7 @@ class TestBuildNousSubscriptionPrompt:
features={
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
@@ -464,6 +465,7 @@ class TestBuildNousSubscriptionPrompt:
features={
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
@@ -112,8 +112,13 @@ class TestCallLlmUnsupportedTemperatureRetry:
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
assert first_kwargs["temperature"] == 0.3
assert "temperature" not in retry_kwargs
# other kwargs preserved
assert retry_kwargs["max_tokens"] == 500
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
# (#34530) — auxiliary calls let the model max out its own output — so
# it must be absent in BOTH the first and retry kwargs. Use a kwarg that
# actually survives (model) to prove the retry preserves the rest.
assert "max_tokens" not in first_kwargs
assert "max_tokens" not in retry_kwargs
assert retry_kwargs["model"] == first_kwargs["model"]
def test_non_temperature_400_does_not_retry_as_temperature(self):
"""Unrelated 400s (e.g. bad tool role) must not silently drop temp."""
@@ -207,7 +212,11 @@ class TestAsyncCallLlmUnsupportedTemperatureRetry:
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
assert first_kwargs["temperature"] == 0.3
assert "temperature" not in retry_kwargs
assert retry_kwargs["max_tokens"] == 500
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
# (#34530); assert it's absent and that model survives the retry.
assert "max_tokens" not in first_kwargs
assert "max_tokens" not in retry_kwargs
assert retry_kwargs["model"] == first_kwargs["model"]
@pytest.mark.asyncio
async def test_async_non_temperature_400_does_not_retry(self):
+105
View File
@@ -11,6 +11,7 @@ def _make_cli():
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._session_db = MagicMock()
cli_obj._pending_resume_sessions = None
# _handle_resume_command now triggers _display_resumed_history (#31695),
# which reads self.resume_display. "minimal" short-circuits the recap so
# the test only exercises session-switch behavior.
@@ -116,3 +117,107 @@ class TestCliResumeCommand:
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert "<half" in printed
class TestPendingResumeNumberedSelection:
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
Regression coverage for #34584: previously, running `/resume` (no args)
printed the recent-sessions list but left no selection state armed, so
typing just `3` on the next line was sent to the agent as chat instead of
resuming session #3.
"""
def test_bare_resume_arms_pending_selection(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._show_recent_sessions = MagicMock(return_value=True)
with patch("cli._cprint"):
cli_obj._handle_resume_command("/resume")
assert cli_obj._pending_resume_sessions == sessions
def test_bare_resume_no_sessions_does_not_arm(self):
cli_obj = _make_cli()
cli_obj._show_recent_sessions = MagicMock(return_value=False)
cli_obj._list_recent_sessions = MagicMock(return_value=[])
with patch("cli._cprint"):
cli_obj._handle_resume_command("/resume")
assert cli_obj._pending_resume_sessions is None
def test_pending_number_resumes_selected_session(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._pending_resume_sessions = sessions
# _handle_resume_command("/resume 2") re-resolves the index via
# _list_recent_sessions, so it must return the same list.
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_messages_as_conversation.return_value = [
{"role": "user", "content": "hello"},
]
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint"),
):
consumed = cli_obj._consume_pending_resume_selection("2")
assert consumed is True
assert cli_obj.session_id == "sess_001"
# One-shot: prompt is disarmed after consuming.
assert cli_obj._pending_resume_sessions is None
def test_pending_out_of_range_consumed_with_message(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
with patch("cli._cprint") as mock_cprint:
consumed = cli_obj._consume_pending_resume_selection("9")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
# An out-of-range number is still consumed (not sent to the agent),
# and the prompt is disarmed.
assert consumed is True
assert "out of range" in printed.lower()
assert cli_obj.session_id == "current_session"
assert cli_obj._pending_resume_sessions is None
def test_pending_non_numeric_falls_through_and_disarms(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
with patch("cli._cprint"):
consumed = cli_obj._consume_pending_resume_selection("hello there")
# Free text is NOT consumed (caller treats it as chat), but the
# one-shot prompt is disarmed so a later number isn't hijacked.
assert consumed is False
assert cli_obj._pending_resume_sessions is None
def test_no_pending_returns_false(self):
cli_obj = _make_cli()
assert cli_obj._pending_resume_sessions is None
assert cli_obj._consume_pending_resume_selection("3") is False
def test_pending_disarmed_by_other_command(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
# Stub out the help handler so process_command("/help") is cheap.
cli_obj.show_help = MagicMock()
cli_obj.process_command("/help")
# A non-resume command disarms the one-shot prompt (#34584).
assert cli_obj._pending_resume_sessions is None
+119
View File
@@ -0,0 +1,119 @@
"""Tests for /compress here [N] — boundary-aware partial compression.
Verifies the CLI handler (_manual_compress) splits the history, compresses
only the head, and re-appends the verbatim tail. Inspired by Claude Code's
Rewind "Summarize up to here" action (v2.1.139, May 2026).
"""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
# 8 messages = 4 exchanges.
h: list[dict[str, str]] = []
for i in range(4):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
def _wire_agent(shell, compressed_head):
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.session_id = None
shell.agent.tools = None
shell.agent._compress_context.return_value = (compressed_head, "")
def test_compress_here_compresses_head_only(capsys):
"""/compress here 2 passes only the head to _compress_context."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
# Pretend compression collapses the head into a single summary message.
summary = [{"role": "user", "content": "[summary of earlier turns]"}]
_wire_agent(shell, summary)
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here 2")
# _compress_context should have been called with the HEAD only
# (everything before the last 2 user-starts = first 4 messages).
shell.agent._compress_context.assert_called_once()
call = shell.agent._compress_context.call_args
passed_head = call.args[0]
assert passed_head == history[:4]
# focus_topic must be None in partial mode (modes are exclusive).
assert call.kwargs.get("focus_topic") is None
def test_compress_here_reappends_verbatim_tail(capsys):
"""The most recent exchanges are preserved verbatim after the summary."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
# Head compresses to an assistant-role summary so the seam
# (assistant -> user tail) is already valid — tail rides along whole.
summary = [{"role": "assistant", "content": "[summary]"}]
_wire_agent(shell, summary)
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here 2")
# Result = compressed head + verbatim tail (last 2 exchanges).
assert shell.conversation_history == summary + history[4:]
# Tail boundary keeps role alternation valid (tail starts on user).
assert history[4]["role"] == "user"
# No consecutive same-role user/assistant messages anywhere.
roles = [m["role"] for m in shell.conversation_history
if m["role"] in ("user", "assistant")]
assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1))
def test_compress_here_banner_mentions_summarizing_up_to_here(capsys):
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, [{"role": "user", "content": "[summary]"}])
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here")
out = capsys.readouterr().out
assert "Summarizing up to here" in out
assert "verbatim" in out
def test_bare_compress_still_full(capsys):
"""/compress with no args compresses the whole history (full mode)."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, list(history))
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress")
call = shell.agent._compress_context.call_args
# Full mode passes the entire history as the head.
assert call.args[0] == history
out = capsys.readouterr().out
assert "Summarizing up to here" not in out
def test_focus_still_works(capsys):
"""/compress <focus> keeps the existing focus behavior."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, list(history))
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress database schema")
call = shell.agent._compress_context.call_args
assert call.args[0] == history
assert call.kwargs.get("focus_topic") == "database schema"
+198
View File
@@ -0,0 +1,198 @@
"""Tests for hermes_cli.partial_compress — the pure split/parse helpers
behind ``/compress here [N]`` (boundary-aware "summarize up to here").
Inspired by Claude Code's Rewind "Summarize up to here" action.
"""
from hermes_cli.partial_compress import (
DEFAULT_KEEP_LAST,
MAX_KEEP_LAST,
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
def _history(n_pairs: int) -> list[dict[str, str]]:
"""Build n_pairs of (user, assistant) exchanges."""
h: list[dict[str, str]] = []
for i in range(n_pairs):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
# ── parse_partial_compress_args ──────────────────────────────────────
def test_empty_args_is_full_compress():
partial, keep, focus = parse_partial_compress_args("")
assert partial is False
assert keep == DEFAULT_KEEP_LAST
assert focus is None
def test_here_defaults_keep_last():
partial, keep, focus = parse_partial_compress_args("here")
assert partial is True
assert keep == DEFAULT_KEEP_LAST
assert focus is None
def test_here_with_count():
partial, keep, focus = parse_partial_compress_args("here 4")
assert partial is True
assert keep == 4
assert focus is None
def test_up_to_here_alias():
partial, keep, focus = parse_partial_compress_args("up to here 3")
assert partial is True
assert keep == 3
assert focus is None
def test_keep_flag_forms():
for arg in ("--keep 5", "-k 5", "--keep=5"):
partial, keep, focus = parse_partial_compress_args(arg)
assert partial is True, arg
assert keep == 5, arg
assert focus is None, arg
def test_focus_topic_when_not_boundary_form():
partial, keep, focus = parse_partial_compress_args("database schema")
assert partial is False
assert focus == "database schema"
def test_here_count_clamped_low_and_high():
_, keep_low, _ = parse_partial_compress_args("here 0")
assert keep_low == 1
_, keep_high, _ = parse_partial_compress_args(f"here {MAX_KEEP_LAST + 50}")
assert keep_high == MAX_KEEP_LAST
def test_here_garbage_count_falls_back_to_default():
partial, keep, focus = parse_partial_compress_args("here lots")
assert partial is True
assert keep == DEFAULT_KEEP_LAST
# ── split_history_for_partial_compress ───────────────────────────────
def test_split_keeps_last_n_exchanges():
h = _history(5) # 10 messages: u0 a0 u1 a1 u2 a2 u3 a3 u4 a4
head, tail = split_history_for_partial_compress(h, keep_last=2)
# Keep last 2 user-starts → tail begins at u3 (index 6).
assert tail == h[6:]
assert head == h[:6]
# Tail must begin on a user turn (role-alternation safety).
assert tail[0]["role"] == "user"
def test_split_default_keep():
h = _history(4) # 8 messages
head, tail = split_history_for_partial_compress(h, keep_last=DEFAULT_KEEP_LAST)
assert tail[0]["role"] == "user"
assert head + tail == h
assert len(head) > 0
def test_split_tail_always_starts_on_user():
# Tool messages interleaved — tail must still snap to a user turn.
h = [
{"role": "user", "content": "u0"},
{"role": "assistant", "content": "a0"},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "tool", "content": "t1"},
{"role": "assistant", "content": "a1b"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "a2"},
]
head, tail = split_history_for_partial_compress(h, keep_last=1)
assert tail[0]["role"] == "user"
assert tail[0]["content"] == "u2"
assert head + tail == h
def test_split_degenerate_returns_no_tail():
# keep_last larger than the number of exchanges → nothing to compress.
h = _history(2) # 4 messages, 2 user turns
head, tail = split_history_for_partial_compress(h, keep_last=5)
# Boundary lands at the first user turn → head empty → signal full.
assert tail == []
assert head == h
def test_split_empty_history():
head, tail = split_history_for_partial_compress([], keep_last=2)
assert head == []
assert tail == []
def test_split_rejoin_preserves_all_messages():
h = _history(6)
head, tail = split_history_for_partial_compress(h, keep_last=3)
assert head + tail == h
# ── rejoin_compressed_head_and_tail (seam-alternation guard) ─────────
def _roles(msgs):
return [m["role"] for m in msgs if m["role"] in ("user", "assistant")]
def _no_consecutive_dupes(msgs):
r = _roles(msgs)
return all(r[i] != r[i + 1] for i in range(len(r) - 1))
def test_rejoin_valid_seam_assistant_then_user():
# Normal case: head ends on assistant, tail starts on user → valid.
head = [{"role": "user", "content": "[summary]"},
{"role": "assistant", "content": "ack"}]
tail = [{"role": "user", "content": "next"},
{"role": "assistant", "content": "reply"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert out == head + tail
assert _no_consecutive_dupes(out)
def test_rejoin_user_user_seam_merges():
# Degenerate head ending on a user summary; tail starts on user.
head = [{"role": "user", "content": "[summary of head]"}]
tail = [{"role": "user", "content": "latest question"},
{"role": "assistant", "content": "answer"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert _no_consecutive_dupes(out), out
# The two user messages were merged into one.
assert out[0]["content"] == "[summary of head]\n\nlatest question"
assert out[1] == {"role": "assistant", "content": "answer"}
def test_rejoin_assistant_assistant_seam_merges():
head = [{"role": "user", "content": "q"},
{"role": "assistant", "content": "head end"}]
tail = [{"role": "assistant", "content": "tail start"},
{"role": "user", "content": "u"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert _no_consecutive_dupes(out), out
assert out[-2]["content"] == "head end\n\ntail start"
def test_rejoin_empty_tail_returns_head():
head = [{"role": "user", "content": "x"}]
assert rejoin_compressed_head_and_tail(head, []) == head
def test_rejoin_tool_seam_left_alone():
# tool->tool is the one legal repetition; don't merge.
head = [{"role": "user", "content": "q"}, {"role": "tool", "content": "t1"}]
tail = [{"role": "user", "content": "u"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert out == head + tail
@@ -0,0 +1,116 @@
"""Regression guard for issue #34569 — inline /steer (and /model) submit
must repaint the input area after clearing the buffer.
Mechanism of the bug
--------------------
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
thread while the agent is running. Those branches called
``buffer.reset(append_to_history=True)`` but unlike every *other*
early-return branch in the handler did NOT call ``event.app.invalidate()``.
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
output above the prompt and never triggers a prompt_toolkit redraw), the
just-cleared input area could keep showing the submitted ``/steer <text>``
until some unrelated redraw fired. The user saw their submitted text as if
it were unsent and could accidentally re-submit it.
This test pins the contract structurally: inside ``handle_enter``, any
inline-command early-return that resets the buffer must be followed by an
``event.app.invalidate()`` before its ``return``. It is an *invariant*
(every reset-then-return repaints), not a snapshot of current source.
"""
from __future__ import annotations
import ast
from pathlib import Path
def _load_handle_enter_node() -> ast.FunctionDef:
"""Extract the ``handle_enter`` nested function node from cli.py."""
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
target = None
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
target = node
break
assert target is not None, "handle_enter closure not found in cli.py"
return target
def _is_buffer_reset(node: ast.stmt) -> bool:
"""True if the statement is ``...current_buffer.reset(...)``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "reset"
def _is_invalidate(node: ast.stmt) -> bool:
"""True if the statement is ``event.app.invalidate()``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
"""Find every statement sequence (a block body/orelse/finalbody) within
``handle_enter`` that contains a ``buffer.reset()`` call."""
blocks: list[list[ast.stmt]] = []
for node in ast.walk(func):
for attr in ("body", "orelse", "finalbody"):
seq = getattr(node, attr, None)
if not isinstance(seq, list):
continue
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
blocks.append(seq)
return blocks
def test_inline_command_reset_branches_invalidate():
"""Every handle_enter branch that resets the buffer and then returns must
invalidate the app first (issue #34569)."""
func = _load_handle_enter_node()
reset_blocks = _collect_reset_blocks(func)
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
offenders = []
for seq in reset_blocks:
for i, stmt in enumerate(seq):
if not _is_buffer_reset(stmt):
continue
# Find the next return after this reset in the same block.
ret_idx = None
for j in range(i + 1, len(seq)):
if isinstance(seq[j], ast.Return):
ret_idx = j
break
if ret_idx is None:
# reset not directly followed by a return in this block
# (e.g. the fall-through reset at the end of the handler) —
# the next user input naturally repaints, so skip.
continue
between = seq[i + 1 : ret_idx]
if not any(_is_invalidate(s) for s in between):
offenders.append(ast.dump(stmt))
assert not offenders, (
"handle_enter has reset-then-return branch(es) that never call "
"event.app.invalidate() — the input area can keep showing the "
"submitted text (issue #34569). Offending reset stmts:\n"
+ "\n".join(offenders)
)
if __name__ == "__main__": # pragma: no cover
test_inline_command_reset_branches_invalidate()
print("ok")
@@ -0,0 +1,202 @@
"""Tests for the outbound silence-narration filter (anti-loop control).
See the gateway delivery path: hallucinated "silence" tokens like ``*(silent)*``
are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning
loop that crashes a model with "no content after all retries".
"""
import pytest
from gateway.config import GatewayConfig, Platform
from gateway.delivery import (
DeliveryRouter,
DeliveryTarget,
_is_silence_narration,
)
# --- Truth table -----------------------------------------------------------
POSITIVE_CASES = [
"*(silent)*",
"*Silence.*",
"🔇",
".",
"",
"...",
"(silent)",
"_silent_",
"silent",
" *(silent)* ",
"`silent`",
"~silent~",
"Silence",
"no response",
"No Reply.",
]
NEGATIVE_CASES = [
"Silence is golden — here is the plan...",
"Silent install completed",
"The deployment ran silently in the background",
"ok",
"👍",
"Here is the result:\n\n- item one\n- item two",
"I have nothing to add, but here is why: the build is green.",
"silently", # word boundary — trailing letters mean it isn't a bare token
"no responses were collected from the survey",
# A 64+ char string that opens with a silence token must not be dropped.
"silent " + "x" * 70,
"",
" ",
]
@pytest.mark.parametrize("content", POSITIVE_CASES)
def test_is_silence_narration_positive(content):
assert _is_silence_narration(content) is True
@pytest.mark.parametrize("content", NEGATIVE_CASES)
def test_is_silence_narration_negative(content):
assert _is_silence_narration(content) is False
def test_is_silence_narration_none_safe():
assert _is_silence_narration(None) is False
def test_length_guard_rejects_long_strings():
# Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.
assert _is_silence_narration("." * 65) is False
assert _is_silence_narration("." * 64) is True
# --- Integration through DeliveryRouter ------------------------------------
class RecordingAdapter:
def __init__(self):
self.calls = []
async def send(self, chat_id, content, metadata=None):
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
return {"success": True}
@pytest.mark.asyncio
async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert adapter.calls == [] # adapter.send never invoked
assert result == {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
@pytest.mark.asyncio
async def test_real_message_is_delivered(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(
target, "Silence is golden — here is the plan...", metadata=None
)
assert len(adapter.calls) == 1
assert adapter.calls[0]["content"] == "Silence is golden — here is the plan..."
assert result == {"success": True}
@pytest.mark.asyncio
async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
adapter = RecordingAdapter()
config = GatewayConfig(filter_silence_narration=False)
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert len(adapter.calls) == 1
assert adapter.calls[0]["content"] == "*(silent)*"
assert result == {"success": True}
@pytest.mark.asyncio
async def test_env_override_disables_filter(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "0")
adapter = RecordingAdapter()
# Config default is True, but env override wins.
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "🔇", metadata=None)
assert len(adapter.calls) == 1
assert result == {"success": True}
@pytest.mark.asyncio
async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "1")
adapter = RecordingAdapter()
# Config says off, env override forces on.
config = GatewayConfig(filter_silence_narration=False)
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
target = DeliveryTarget.parse("discord:99887766")
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
assert adapter.calls == []
assert result["filtered"] == "silence_narration"
@pytest.mark.asyncio
async def test_local_delivery_not_filtered(tmp_path, monkeypatch):
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
router = DeliveryRouter(GatewayConfig(), adapters={})
results = await router.deliver(
content="*(silent)*",
targets=[DeliveryTarget.parse("local")],
job_id="silence-job",
)
# Local path saved the file (no loop risk) and was not filtered.
local_result = results["local"]
assert local_result["success"] is True
saved_path = local_result["result"]["path"]
assert saved_path.endswith(".md")
# --- Config round-trip ------------------------------------------------------
def test_config_flag_defaults_true():
assert GatewayConfig().filter_silence_narration is True
def test_config_from_dict_parses_flag():
cfg = GatewayConfig.from_dict({"filter_silence_narration": False})
assert cfg.filter_silence_narration is False
def test_config_to_dict_roundtrip():
cfg = GatewayConfig(filter_silence_narration=False)
assert cfg.to_dict()["filter_silence_narration"] is False
restored = GatewayConfig.from_dict(cfg.to_dict())
assert restored.filter_silence_narration is False
+59
View File
@@ -4883,3 +4883,62 @@ class TestFeishuMentionEndToEnd(unittest.TestCase):
# Body: leading @Hermes stripped, Alice preserved, trailing text intact.
self.assertIn("@Alice review the spec with Alice", event.text)
self.assertNotIn("@Hermes @Alice", event.text)
class TestChatLockEviction(unittest.TestCase):
"""_get_chat_lock is LRU-bounded so _chat_locks cannot grow unbounded."""
def _make_adapter(self, max_size=5):
import collections as _collections
from gateway.platforms.feishu import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._chat_locks = _collections.OrderedDict()
adapter.CHAT_LOCK_MAX_SIZE = max_size
return adapter
def test_chat_locks_is_ordered_dict(self):
import collections as _collections
adapter = self._make_adapter()
self.assertIsInstance(adapter._chat_locks, _collections.OrderedDict)
def test_same_id_returns_same_lock_and_stays_bounded(self):
adapter = self._make_adapter(max_size=5)
locks = [adapter._get_chat_lock(f"c{i}") for i in range(5)]
self.assertEqual(len(adapter._chat_locks), 5)
# Re-requesting an existing id returns the identical lock, no growth.
self.assertIs(adapter._get_chat_lock("c2"), locks[2])
self.assertEqual(len(adapter._chat_locks), 5)
def test_lru_eviction_respects_recent_access(self):
adapter = self._make_adapter(max_size=5)
for i in range(5):
adapter._get_chat_lock(f"c{i}")
# Touch c0 so it is no longer the LRU entry, then add a new chat.
adapter._get_chat_lock("c0")
adapter._get_chat_lock("c_new")
self.assertEqual(len(adapter._chat_locks), 5)
self.assertNotIn("c1", adapter._chat_locks) # c1 was the true LRU
self.assertIn("c0", adapter._chat_locks)
self.assertIn("c_new", adapter._chat_locks)
def test_eviction_skips_held_locks(self):
adapter = self._make_adapter(max_size=3)
async def _run():
held = adapter._get_chat_lock("held")
await held.acquire()
try:
adapter._get_chat_lock("x")
adapter._get_chat_lock("y")
# At capacity; "held" is LRU but locked, so "x" should go instead.
adapter._get_chat_lock("z")
self.assertIn("held", adapter._chat_locks)
self.assertNotIn("x", adapter._chat_locks)
self.assertEqual(len(adapter._chat_locks), 3)
finally:
held.release()
asyncio.run(_run())
+134
View File
@@ -5,6 +5,10 @@ Verifies that MEDIA tags (e.g., from TTS tool) are only extracted from
messages in the CURRENT turn, not from the full conversation history.
This prevents voice messages from accumulating and being sent multiple
times per reply. (Regression test for #160)
Also covers #34608: a stale MEDIA: path emitted by an execute_code /
make_image tool several turns earlier must not leak onto a later
text-only reply, even when the path-based dedup set fails to capture it.
"""
import pytest
@@ -43,6 +47,37 @@ def extract_media_tags_fixed(result_messages, history_len):
return media_tags, has_voice_directive
def extract_media_tags_production(result_messages, history_len, history_media_paths):
"""Mirror of the production scan in gateway/run.py after the #34608 fix.
Primary guard: scope the scan to the current turn via ``history_len``
slicing (matching how ``agent_history`` is passed as
``conversation_history`` into ``run_conversation``). Secondary guard:
path-based dedup against ``history_media_paths`` (the #160 compression-safe
fallback, also used when compression shrinks the list below history_len).
"""
media_tags = []
has_voice_directive = False
if len(result_messages) >= history_len and history_len:
scan_msgs = result_messages[history_len:]
else:
scan_msgs = result_messages
for msg in scan_msgs:
if msg.get("role") == "tool" or msg.get("role") == "function":
content = msg.get("content", "")
if "MEDIA:" in content:
for match in re.finditer(r'MEDIA:(\S+)', content):
path = match.group(1).strip().rstrip('",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
has_voice_directive = True
return media_tags, has_voice_directive
def extract_media_tags_broken(result_messages):
"""
The BROKEN behavior: extract MEDIA tags from ALL messages including history.
@@ -180,5 +215,104 @@ class TestMediaExtraction:
assert len(unique) == 2 # After dedup: same.ogg and different.ogg
class TestStaleToolMediaLeak:
"""Regression tests for #34608.
A MEDIA: path emitted by an execute_code / make_image tool several turns
earlier remains in the full conversation message list. A later text-only
reply (zero MEDIA directives) must NOT attach that stale image.
The production code previously 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), the stale
path is absent from the dedup set and leaks. Turn-scoped slicing closes
this class of bug deterministically.
"""
def test_stale_execute_code_media_not_attached_to_text_only_reply(self):
"""The exact #34608 scenario: make_image cover from an earlier turn."""
# Prior turn generated an image via execute_code stdout.
history = [
{"role": "user", "content": "Make a cover image"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "1", "function": {"name": "execute_code"}}]},
{"role": "tool", "tool_call_id": "1",
"content": "Generating cover...\nMEDIA:/tmp/seosmi_cover.png\nDone."},
{"role": "assistant", "content": "Here is your cover."},
]
# Current turn: plain text status update, zero MEDIA directives.
new_messages = [
{"role": "user", "content": "What skill version am I on?"},
{"role": "assistant", "content": "You're on v0.15.1."},
]
all_messages = history + new_messages
history_len = len(history)
# Simulate the dedup set FAILING to capture the stale path (the real
# #34608 condition: replayable-history reconstruction diverged from
# the in-memory tool content, so the path is not in the set).
history_media_paths = set()
tags, voice = extract_media_tags_production(
all_messages, history_len, history_media_paths
)
assert tags == [], (
"Stale tool MEDIA from a prior turn must not leak onto a "
f"later text-only reply, got {tags}"
)
assert voice is False
# The pre-fix production behaviour (scan everything, dedup only) would
# have leaked the stale path when the dedup set missed it.
broken_tags, _ = extract_media_tags_broken(all_messages)
assert any("seosmi_cover.png" in t for t in broken_tags), (
"Sanity: the unscoped scan does surface the stale path"
)
def test_current_turn_media_still_attached_when_dedup_set_empty(self):
"""Turn-scoping must not suppress genuinely new media."""
history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
new_messages = [
{"role": "user", "content": "Make me a cover image"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "9", "function": {"name": "execute_code"}}]},
{"role": "tool", "tool_call_id": "9",
"content": "MEDIA:/tmp/fresh_cover.png"},
{"role": "assistant", "content": "Here it is."},
]
all_messages = history + new_messages
tags, _ = extract_media_tags_production(
all_messages, len(history), set()
)
assert len(tags) == 1 and "fresh_cover.png" in tags[0]
def test_compression_shrink_falls_back_to_path_dedup(self):
"""When the list is shorter than history_len (mid-run compression),
fall back to scanning everything with path-based dedup so the #160
compression-safe guarantee is preserved."""
# Post-compression list is shorter than the original history length.
compressed_messages = [
{"role": "user", "content": "summary so far..."},
{"role": "tool", "tool_call_id": "7",
"content": "MEDIA:/tmp/old_from_history.png"},
{"role": "assistant", "content": "ok"},
]
original_history_len = 12 # larger than the compressed list
# The old path IS captured in the dedup set here (history scan ran
# before compression), so it must still be excluded.
history_media_paths = {"/tmp/old_from_history.png"}
tags, _ = extract_media_tags_production(
compressed_messages, original_history_len, history_media_paths
)
assert tags == [], (
"On the compression fallback path, path-dedup must still exclude "
f"known-old media, got {tags}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+79
View File
@@ -486,6 +486,22 @@ class TestSend:
call_headers = mock_client.post.call_args[1]["headers"]
assert "X-Markdown" not in call_headers
def test_send_emits_echo_tag_header(self):
"""Outgoing messages carry the echo-prevention tag so the adapter
can recognise and skip its own replies when subscribe topic ==
publish topic (the default config that causes the loop)."""
adapter = self._make_adapter(topic="hermes-in")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"id": "abc123"}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
adapter._http_client = mock_client
_run(adapter.send("hermes-in", "Hello!"))
call_headers = mock_client.post.call_args[1]["headers"]
assert call_headers.get("X-Tags") == _ntfy._ECHO_TAG
# ---------------------------------------------------------------------------
# 8. Inbound message processing (identity invariant — security-critical)
@@ -543,6 +559,47 @@ class TestOnMessage:
_run(adapter._on_message(event))
assert len(calls) == 1
def test_own_tagged_message_skipped(self):
"""An incoming event carrying the adapter's echo tag is the agent's
own reply echoed back by ntfy it must not be dispatched, otherwise
the agent replies to itself forever (issue #34447)."""
adapter = self._make_adapter()
calls = []
async def handler(event):
calls.append(event)
adapter.set_message_handler(handler)
_run(adapter._on_message({
"id": "echo-1",
"event": "message",
"topic": "hermes-in",
"message": "my own reply",
"tags": [_ntfy._ECHO_TAG],
"time": None,
}))
assert calls == []
def test_message_with_other_tags_still_dispatched(self):
"""Tags unrelated to the echo sentinel must not suppress genuine
user messages."""
adapter = self._make_adapter()
calls = []
async def handler(event):
calls.append(event)
adapter.set_message_handler(handler)
_run(adapter._on_message({
"id": "user-1",
"event": "message",
"topic": "hermes-in",
"message": "hello",
"tags": ["warning", "skull"],
"time": None,
}))
assert len(calls) == 1
def test_timestamp_parsed_from_event(self):
from datetime import timezone
adapter = self._make_adapter()
@@ -742,6 +799,28 @@ class TestStandaloneSend:
posted_url = mock_client.post.call_args[0][0]
assert posted_url == "https://ntfy.example.com/hermes-in"
def test_emits_echo_tag_header(self, monkeypatch):
"""Out-of-process cron / send_message deliveries also carry the echo
tag, so a gateway subscribed to the same topic skips them too."""
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
pconfig = MagicMock()
pconfig.extra = {"topic": "hermes-in"}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"id": "id-99"}
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(_ntfy, "httpx") as mock_httpx:
mock_httpx.AsyncClient.return_value = mock_client
_run(_standalone_send(pconfig, "hermes-in", "hi"))
headers = mock_client.post.call_args[1]["headers"]
assert headers.get("X-Tags") == _ntfy._ECHO_TAG
def test_emits_bearer_token_when_configured(self, monkeypatch):
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
pconfig = MagicMock()
+136 -8
View File
@@ -12,12 +12,33 @@ See issue #33778 for the original Windows session-loss bug report.
"""
import asyncio
import json
import os
import threading
import time
from unittest.mock import MagicMock
from gateway.run import _run_planned_stop_watcher
from gateway import status as status_mod
def _write_self_marker(marker, *, stale: bool = False):
"""Write a planned-stop marker that targets the CURRENT process.
The watcher only fires for markers naming our PID + start_time (the
fix for issue #34597), so tests that expect a fire must write a
self-targeting marker. Pass ``stale=True`` to backdate ``written_at``
past the TTL.
"""
written_at = "2000-01-01T00:00:00+00:00" if stale else status_mod._utc_now_iso()
record = {
"target_pid": os.getpid(),
"target_start_time": status_mod._get_process_start_time(os.getpid()),
"stopper_pid": os.getpid(),
"written_at": written_at,
}
marker.write_text(json.dumps(record), encoding="utf-8")
class _FakeRunner:
@@ -41,11 +62,10 @@ def _make_loop_capturing_calls():
def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
"""When the marker file exists, the watcher must call the shutdown handler."""
"""When a marker targeting THIS process exists, fire the shutdown handler."""
marker = tmp_path / ".gateway-planned-stop.json"
# Patch the marker-path resolver so the watcher polls our temp location.
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
runner = _FakeRunner(running=True, draining=False)
@@ -53,8 +73,8 @@ def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
shutdown_handler = MagicMock(name="shutdown_signal_handler")
stop_event = threading.Event()
# Drop the marker before the thread starts.
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
# Drop a self-targeting marker before the thread starts.
_write_self_marker(marker)
watcher = threading.Thread(
target=_run_planned_stop_watcher,
@@ -114,9 +134,8 @@ def test_watcher_skips_when_runner_already_draining(tmp_path, monkeypatch):
so the watcher backs off once any shutdown is in flight.
"""
marker = tmp_path / ".gateway-planned-stop.json"
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
_write_self_marker(marker)
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
# Already draining — watcher should be a no-op.
@@ -204,9 +223,8 @@ def test_watcher_fires_only_once_when_marker_persists(tmp_path, monkeypatch):
times before the gateway actually shuts down.
"""
marker = tmp_path / ".gateway-planned-stop.json"
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
_write_self_marker(marker)
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
runner = _FakeRunner(running=True, draining=False)
@@ -263,3 +281,113 @@ def test_watcher_tolerates_marker_path_resolution_errors(tmp_path, monkeypatch,
assert not watcher.is_alive(), "Watcher should still honour stop_event after errors"
# No shutdown fired because the marker never reported existence.
assert loop._captured == []
# ---------------------------------------------------------------------------
# Regression coverage for issue #34597:
# A marker left behind by a PREVIOUS gateway instance (different PID, or
# past its TTL) must NOT crash the freshly booted gateway. The watcher
# only fires when the marker targets the current process, and self-heals
# by cleaning up stale/malformed markers.
# ---------------------------------------------------------------------------
def test_watcher_does_not_fire_for_foreign_pid_marker(tmp_path, monkeypatch):
"""A marker naming a DIFFERENT process must not trigger our shutdown.
This is the core #34597 regression: a stale marker from a prior
gateway instance was firing the handler, driving the new gateway into
a false "Received UNKNOWN" shutdown and a watchdog crash loop.
"""
marker = tmp_path / ".gateway-planned-stop.json"
# Foreign PID + a start_time that cannot match ours, freshly written
# so the TTL does NOT remove it — the watcher must still decline.
record = {
"target_pid": os.getpid() + 1,
"target_start_time": -1,
"stopper_pid": os.getpid() + 1,
"written_at": status_mod._utc_now_iso(),
}
marker.write_text(json.dumps(record), encoding="utf-8")
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
runner = _FakeRunner(running=True, draining=False)
loop = _make_loop_capturing_calls()
shutdown_handler = MagicMock(name="shutdown_signal_handler")
stop_event = threading.Event()
watcher = threading.Thread(
target=_run_planned_stop_watcher,
args=(stop_event, runner, loop, shutdown_handler),
kwargs={"poll_interval": 0.05},
daemon=True,
)
watcher.start()
time.sleep(0.3) # several poll cycles
stop_event.set()
watcher.join(timeout=2.0)
assert not watcher.is_alive()
assert loop._captured == [], (
f"Watcher fired on a foreign-PID marker (#34597 regression): {loop._captured}"
)
shutdown_handler.assert_not_called()
# Foreign (but live) marker is left in place — it may still belong to
# the process it names.
assert marker.exists()
def test_watcher_cleans_up_stale_marker_and_keeps_running(tmp_path, monkeypatch):
"""A marker older than the TTL is unlinked and never fires shutdown."""
marker = tmp_path / ".gateway-planned-stop.json"
# Self-targeting but backdated past the TTL: must be treated as dead.
_write_self_marker(marker, stale=True)
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
runner = _FakeRunner(running=True, draining=False)
loop = _make_loop_capturing_calls()
shutdown_handler = MagicMock(name="shutdown_signal_handler")
stop_event = threading.Event()
watcher = threading.Thread(
target=_run_planned_stop_watcher,
args=(stop_event, runner, loop, shutdown_handler),
kwargs={"poll_interval": 0.05},
daemon=True,
)
watcher.start()
time.sleep(0.3)
stop_event.set()
watcher.join(timeout=2.0)
assert not watcher.is_alive()
assert loop._captured == [], "Stale marker must not fire shutdown"
shutdown_handler.assert_not_called()
assert not marker.exists(), "Stale marker should have been cleaned up"
def test_planned_stop_marker_targets_self_probe_is_non_destructive(tmp_path, monkeypatch):
"""The probe returns True for a self-marker WITHOUT unlinking it.
The shutdown handler performs the authoritative consume on its own
thread, so the watcher's probe must leave a matching marker intact.
"""
marker = tmp_path / ".gateway-planned-stop.json"
_write_self_marker(marker)
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
assert status_mod.planned_stop_marker_targets_self() is True
assert marker.exists(), "Probe must not consume a matching marker"
# Idempotent: still True on a second call.
assert status_mod.planned_stop_marker_targets_self() is True
def test_planned_stop_marker_targets_self_drops_malformed(tmp_path, monkeypatch):
"""A malformed marker reports False and is cleaned up."""
marker = tmp_path / ".gateway-planned-stop.json"
marker.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
assert status_mod.planned_stop_marker_targets_self() is False
+48
View File
@@ -362,6 +362,54 @@ class TestExtractMedia:
assert "[[as_document]]" not in cleaned
class TestMediaExtensionAllowlistParity:
"""Regression coverage for issue #34517 — the MEDIA: extension black hole.
extract_media used to carry a narrow extension allowlist that omitted
.md/.json/.yaml/.xml/.html etc., while extract_local_files had a broad one.
Combined with an unconditional ``MEDIA:\\s*\\S+`` strip at the dispatch
sites, an unmatched MEDIA: tag for one of those extensions was deleted from
the body before extract_local_files could pick up the bare path the file
was silently dropped. Both extractors now derive from the single
MEDIA_DELIVERY_EXTS source of truth, and the strip is anchored to that set.
"""
DROPPED_BEFORE = ["md", "json", "yaml", "yml", "xml", "html", "htm",
"tsv", "svg"]
def test_previously_dropped_extensions_now_extract(self):
for ext in self.DROPPED_BEFORE:
path = f"/tmp/report.{ext}"
media, _ = BasePlatformAdapter.extract_media(f"Here: MEDIA:{path}")
assert media == [(path, False)], f".{ext} should extract via MEDIA:"
def test_extract_media_and_local_files_share_one_extension_set(self):
from gateway.platforms.base import MEDIA_DELIVERY_EXTS
# Both functions reference MEDIA_DELIVERY_EXTS; assert the documents
# that motivated the bug are present in the shared set.
for ext in (".md", ".json", ".yaml", ".yml", ".xml", ".html", ".htm"):
assert ext in MEDIA_DELIVERY_EXTS
def test_unknown_extension_not_black_holed_by_cleanup(self):
"""A MEDIA: tag with an unknown extension is NOT stripped from the
body it survives so extract_local_files can still see the bare path,
rather than vanishing entirely (the core of issue #34517)."""
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
text = "Saved to MEDIA:/tmp/data.weirdext done"
media, _ = BasePlatformAdapter.extract_media(text)
assert media == [] # unknown extension is not a deliverable MEDIA tag
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text)
assert "/tmp/data.weirdext" in stripped # path preserved, not dropped
def test_known_extension_tag_is_stripped_from_body(self):
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
text = "Here is your report: MEDIA:/tmp/report.md"
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text).strip()
assert "MEDIA:" not in stripped
assert "/tmp/report.md" not in stripped
assert "Here is your report:" in stripped
class TestMediaDeliveryPathValidation:
def _patch_roots(self, monkeypatch, *roots):
monkeypatch.setattr(
+69
View File
@@ -268,6 +268,75 @@ async def test_session_chat_stream_emits_lifecycle_events_and_keepalive_safe_sha
assert "event: done" in body
@pytest.mark.asyncio
async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
"""run.completed must include the full interleaved turn transcript so a
client that lost intermediate (pre-tool-call) assistant text from the live
delta stream can reconcile without a separate /messages fetch. Refs #34703.
"""
import json as _json
session_id = session_db.create_session("transcript-session", "api_server")
async def fake_run(**kwargs):
# Stream the intermediate planning text the way a real turn would.
kwargs["stream_delta_callback"]("Let me search for that:")
kwargs["stream_delta_callback"]("Here is the summary.")
result = {
"final_response": "Here is the summary.",
"session_id": session_id,
"messages": [
{"role": "user", "content": "search then summarize"},
{
"role": "assistant",
"content": "Let me search for that:",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "web_search", "arguments": "{}"},
}
],
},
{"role": "tool", "content": "results", "tool_call_id": "call_1", "tool_name": "web_search"},
{"role": "assistant", "content": "Here is the summary."},
],
}
return result, {"total_tokens": 6}
app = _create_session_app(adapter)
with patch.object(adapter, "_run_agent", side_effect=fake_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat/stream",
json={"message": "search then summarize"},
)
assert resp.status == 200
body = await resp.text()
# Pull the run.completed event payload out of the SSE body.
run_completed_payload = None
for block in body.split("\n\n"):
if "event: run.completed" in block:
for line in block.splitlines():
if line.startswith("data: "):
run_completed_payload = _json.loads(line[len("data: "):])
break
assert run_completed_payload is not None, body
messages = run_completed_payload.get("messages")
assert isinstance(messages, list) and messages, run_completed_payload
# The colon-ended intermediate text that preceded the tool call must be present.
contents = [m.get("content") for m in messages]
assert "Let me search for that:" in contents
assert "Here is the summary." in contents
# No prior-turn user message should leak into the per-turn slice.
assert all(m.get("role") in ("assistant", "tool") for m in messages)
# The tool call is preserved alongside the intermediate text.
assert any(m.get("tool_calls") for m in messages)
@pytest.mark.asyncio
async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):
app = _create_session_app(auth_adapter)
+95
View File
@@ -707,6 +707,33 @@ class TestTakeoverMarker:
assert result is False
def test_consume_returns_true_on_windows_when_start_time_unavailable(
self, tmp_path, monkeypatch
):
"""Takeover consume must also recognise a self-marker on platforms
without ``/proc`` (macOS / native Windows).
``consume_takeover_marker_for_self`` shares ``_consume_pid_marker_for_self``
with the planned-stop path, so the same start_time fallback applies:
a ``--replace`` SIGTERM on Windows (where start_time is None on both
sides) must be recognised as a planned takeover and exit 0, not be
misclassified as an unexpected UNKNOWN exit. With start_time
unavailable we fall back to PID equality alone, bounded by the TTL.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Simulate Windows: no start_time available for any PID.
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
ok = status.write_takeover_marker(target_pid=os.getpid())
assert ok is True
payload = json.loads((tmp_path / ".gateway-takeover.json").read_text())
assert payload["target_start_time"] is None
result = status.consume_takeover_marker_for_self()
assert result is True
assert not (tmp_path / ".gateway-takeover.json").exists()
def test_consume_returns_false_when_marker_missing(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -899,6 +926,74 @@ class TestPlannedStopMarker:
assert ok is False
def test_consume_returns_true_on_windows_when_start_time_unavailable(
self, tmp_path, monkeypatch
):
"""Regression for #34597: a legitimate stop must be recognised on
platforms without ``/proc``.
``_get_process_start_time`` returns None on macOS / native Windows
(no ``/proc/<pid>/stat``). The planned-stop watcher only runs there,
so if the authoritative consume required a non-None start_time match
it would always return False and ``hermes gateway stop`` would be
misclassified as an unexpected ``UNKNOWN`` exit, exit 1, and revived
by the service manager (the very crash loop #34597 set out to fix).
With start_time unavailable on BOTH sides we fall back to PID
equality alone, bounded by the marker TTL.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Simulate Windows: no start_time available for any PID.
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
ok = status.write_planned_stop_marker(target_pid=os.getpid())
assert ok is True
# Marker carries a null start_time, exactly as written on Windows.
payload = json.loads((tmp_path / ".gateway-planned-stop.json").read_text())
assert payload["target_start_time"] is None
result = status.consume_planned_stop_marker_for_self()
assert result is True
assert not (tmp_path / ".gateway-planned-stop.json").exists()
def test_consume_still_rejects_foreign_pid_when_start_time_unavailable(
self, tmp_path, monkeypatch
):
"""The PID-only fallback must NOT match a marker naming another PID.
Falling back to PID equality when start_time is unknown must remain
a PID check a marker for a different process is never ours.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
ok = status.write_planned_stop_marker(target_pid=os.getpid() + 9999)
assert ok is True
result = status.consume_planned_stop_marker_for_self()
assert result is False
def test_consume_still_rejects_start_time_mismatch_when_both_known(
self, tmp_path, monkeypatch
):
"""PID-reuse defence is preserved when BOTH start_times are present.
The Windows fallback only relaxes matching when a start_time is
unavailable. When both sides report one (Linux), a mismatch must
still reject otherwise PID reuse could resurrect a stale marker.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 100)
status.write_planned_stop_marker(target_pid=os.getpid())
# Simulate PID reuse: same PID, different start_time.
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 9999)
result = status.consume_planned_stop_marker_for_self()
assert result is False
class TestReadProcessCmdlinePsFallback:
"""Tests for _read_process_cmdline falling back to ps on non-Linux."""
+3 -3
View File
@@ -97,7 +97,7 @@ async def test_status_command_reports_running_agent_without_interrupt(monkeypatc
result = await runner._handle_message(_make_event("/status"))
assert "**Session ID:** `sess-1`" in result
assert "**Tokens:** 321" in result
assert "**Cumulative API tokens (re-sent each call):** 321" in result
assert "**Agent Running:** Yes ⚡" in result
assert "**Title:**" not in result
running_agent.interrupt.assert_not_called()
@@ -150,7 +150,7 @@ async def test_status_command_reads_token_totals_from_session_db():
result = await runner._handle_message(_make_event("/status"))
# 1000 + 250 + 500 + 100 + 50 = 1,900
assert "**Tokens:** 1,900" in result
assert "**Cumulative API tokens (re-sent each call):** 1,900" in result
@pytest.mark.asyncio
@@ -171,7 +171,7 @@ async def test_status_command_tokens_zero_when_session_db_row_missing():
result = await runner._handle_message(_make_event("/status"))
assert "**Tokens:** 0" in result
assert "**Cumulative API tokens (re-sent each call):** 0" in result
@pytest.mark.asyncio
+102
View File
@@ -1679,3 +1679,105 @@ class TestPreMigrationBackup:
_t.sleep(1.05)
# Update backup must still be there
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
# ---------------------------------------------------------------------------
# Cron jobs auto-restore after silent migration loss (issue #34600)
# ---------------------------------------------------------------------------
class TestRestoreCronJobsIfEmptied:
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
post-migration safety net that restores from the pre-update snapshot."""
@staticmethod
def _seed_jobs(path: Path, jobs):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"jobs": jobs}))
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
from hermes_cli.backup import create_quick_snapshot
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
def test_restores_when_emptied_after_migration(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update: 3 real jobs.
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
snap_id = self._make_snapshot(hermes_home)
assert snap_id
# Migration silently empties the file (valid JSON, zero jobs).
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["restored"] is True
assert result["job_count"] == 3
assert result["snapshot_id"] == snap_id
# The live file now has the jobs back.
restored = json.loads(jobs_path.read_text())
assert len(restored["jobs"]) == 3
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
snap_id = self._make_snapshot(hermes_home)
# Healthy path: file unchanged after update.
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update genuinely had zero jobs; current is also empty.
self._seed_jobs(jobs_path, [])
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
def test_noop_when_live_file_unreadable(self, tmp_path):
"""An unparseable live file is left alone — that's a different failure
mode the user should see, not silently overwrite."""
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [{"id": "a"}])
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text("{ this is not valid json")
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
# File left untouched.
assert jobs_path.read_text() == "{ this is not valid json"
def test_noop_when_snapshot_id_missing(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [])
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
still counted and restored."""
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
jobs_path.parent.mkdir(parents=True, exist_ok=True)
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["job_count"] == 2
+77 -3
View File
@@ -326,6 +326,8 @@ class TestGeneratedSystemdUnits:
assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert "Restart=on-failure" in unit
assert "Restart=always" not in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
@@ -387,6 +389,8 @@ class TestGeneratedSystemdUnits:
assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert "Restart=on-failure" in unit
assert "Restart=always" not in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
@@ -493,7 +497,10 @@ class TestLaunchdServiceRecovery:
label = gateway_cli.get_launchd_label()
domain = gateway_cli._launchd_domain()
assert "--replace" in plist_path.read_text(encoding="utf-8")
plist_text = plist_path.read_text(encoding="utf-8")
assert "<string>gateway</string>" in plist_text
assert "<string>run</string>" in plist_text
assert "--replace" not in plist_text
assert calls[:2] == [
["launchctl", "bootout", f"{domain}/{label}"],
["launchctl", "bootstrap", domain, str(plist_path)],
@@ -1616,7 +1623,8 @@ class TestProfileArg:
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
unit = gateway_cli.generate_systemd_unit(system=False)
assert "--profile mybot" in unit
assert "gateway run --replace" in unit
assert "gateway run" in unit
assert "--replace" not in unit
def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
"""generate_launchd_plist should include --profile in ProgramArguments for named profiles."""
@@ -1628,6 +1636,24 @@ class TestProfileArg:
plist = gateway_cli.generate_launchd_plist()
assert "<string>--profile</string>" in plist
assert "<string>mybot</string>" in plist
assert "<string>--replace</string>" not in plist
def test_gateway_run_args_for_profile_omit_replace(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "get_python_path", lambda: "/venv/bin/python")
default_args = gateway_cli._gateway_run_args_for_profile("default")
named_args = gateway_cli._gateway_run_args_for_profile("mybot")
assert default_args == ["/venv/bin/python", "-m", "hermes_cli.main", "gateway", "run"]
assert named_args == [
"/venv/bin/python",
"-m",
"hermes_cli.main",
"--profile",
"mybot",
"gateway",
"run",
]
def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
@@ -1704,7 +1730,12 @@ class TestSystemUnitPathRemapping:
assert str(root_home) not in unit
# Target user paths should be present
assert "/home/alice" in unit
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
# WorkingDirectory is anchored at the target user's HERMES_HOME (stable,
# always exists) — NOT the source checkout under it. Pinning cwd to the
# checkout is the rot bug fixed alongside this: a relocated/removed
# checkout would crash-loop the unit on CHDIR (status=200).
assert "WorkingDirectory=/home/alice/.hermes" in unit
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" not in unit
class TestDockerAwareGateway:
@@ -2531,3 +2562,46 @@ class TestGatewayCommandCatchesSystemScopeError:
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
assert "System gateway start requires root. Re-run with sudo." in out
assert "('" not in out # no tuple repr leaking through
class TestServiceWorkingDirIsStable:
"""The gateway service must anchor WorkingDirectory at a stable path
(HERMES_HOME), never the source checkout / worktree, so a relocated or
deleted checkout can't crash-loop the unit on CHDIR (status=200).
"""
def test_stable_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
assert Path(gateway_cli._stable_service_working_dir()) == home.resolve()
def test_stable_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
# HERMES_HOME points somewhere that does not exist -> fall back.
missing = tmp_path / "does-not-exist" / ".hermes"
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: missing)
assert gateway_cli._stable_service_working_dir() == str(gateway_cli.PROJECT_ROOT)
def test_user_unit_workingdirectory_is_hermes_home_not_checkout(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
unit = gateway_cli.generate_systemd_unit(system=False)
wd = [l for l in unit.splitlines() if l.startswith("WorkingDirectory=")]
assert wd, "unit has no WorkingDirectory line"
value = wd[0].split("=", 1)[1]
assert Path(value).resolve() == home.resolve()
# The bug class: never pin cwd inside a transient worktree checkout.
assert "/.worktrees/" not in value
def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch):
import re
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
plist = gateway_cli.generate_launchd_plist()
m = re.search(r"<key>WorkingDirectory</key>\s*<string>(.*?)</string>", plist)
assert m, "plist has no WorkingDirectory entry"
assert Path(m.group(1)).resolve() == home.resolve()
assert "/.worktrees/" not in m.group(1)
+55
View File
@@ -595,3 +595,58 @@ class TestMcpLogin:
out = capsys.readouterr().out
assert "no URL" in out or "not an OAuth" in out
def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
"""Probe lists tools without auth (Google Drive), but no token landed.
The server allows tools/list without auth (DCR 400'd), so the probe
succeeds yet no OAuth token exists. Login must NOT claim success it
should warn and point the user at pre-registered client_id config.
"""
_seed_config(tmp_path, {
"googledrive": {
"url": "https://drivemcp.googleapis.com/mcp/v1",
"auth": "oauth",
},
})
# Probe returns tools even though auth never completed.
monkeypatch.setattr(
"hermes_cli.mcp_config._probe_single_server",
lambda name, cfg: [("search_files", "d"), ("read_file_content", "d")],
)
# No token file is created → _oauth_tokens_present() returns False.
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="googledrive"))
out = capsys.readouterr().out
assert "no OAuth token was obtained" in out
assert "Authenticated" not in out
assert "client_id" in out
def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
"""Probe lists tools AND a token exists → report real success."""
_seed_config(tmp_path, {
"realserver": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
})
token_dir = tmp_path / "mcp-tokens"
# cmd_mcp_login wipes tokens before probing, then the real OAuth flow
# writes a fresh token during the probe. Simulate that: the mocked
# probe drops a token file, mirroring a successful authorization.
def mock_probe(name, cfg):
token_dir.mkdir(exist_ok=True)
(token_dir / "realserver.json").write_text('{"access_token": "x"}')
return [("a", "d"), ("b", "d"), ("c", "d")]
monkeypatch.setattr(
"hermes_cli.mcp_config._probe_single_server", mock_probe
)
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="realserver"))
out = capsys.readouterr().out
assert "Authenticated — 3 tool(s) available" in out
assert "no OAuth token" not in out
@@ -403,6 +403,44 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk
assert models["custom:openai-2"] == ["gpt-4.6"]
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:gpt",
current_base_url="https://gateway.example.com",
user_providers={},
custom_providers=[
{
"name": "gpt",
"base_url": "https://gateway.example.com",
"key_env": "GPT_KEY",
"api_mode": "codex_responses",
"model": "gpt-5.5",
},
{
"name": "claude",
"base_url": "https://gateway.example.com",
"key_env": "CLAUDE_KEY",
"api_mode": "anthropic_messages",
"model": "claude-opus-4-8",
},
],
max_models=50,
)
custom = [p for p in providers if p.get("is_user_defined")]
by_slug = {p["slug"]: p for p in custom}
assert set(by_slug) == {"custom:gpt", "custom:claude"}
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
assert by_slug["custom:gpt"]["is_current"] is True
assert by_slug["custom:claude"]["is_current"] is False
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
"""After grouping six entries into one row, total_models must reflect
the full count, and every grouped model appears in the list."""
+2 -2
View File
@@ -218,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
@@ -230,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
assert "web" in has_direct
assert "web" not in already_managed
assert set(unconfigured) == {"image_gen", "tts", "browser"}
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
@@ -59,3 +59,53 @@ def test_docker_detected_via_dockerenv(tmp_path):
def test_recommended_update_command_docker():
from hermes_cli.config import recommended_update_command_for_method
assert "docker pull" in recommended_update_command_for_method("docker")
def test_banner_warns_on_pip_install(tmp_path):
"""The welcome banner surfaces a warning when the install method is pip."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("pip\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
# Wide console so the warning isn't wrapped across lines in the panel.
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" in out
assert "instability" in out
def test_banner_no_pip_warning_on_git_install(tmp_path):
"""Git installs must not show the pip-install warning."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("git\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" not in out
+108
View File
@@ -600,6 +600,114 @@ class TestAliasCollision:
assert result is not None
assert "reserved" in result.lower()
def test_uses_where_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="")
check_alias_collision("mybot")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "where"
def test_uses_which_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="")
check_alias_collision("mybot")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "which"
def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
wrapper_dir = profile_env / ".local" / "bin"
wrapper_dir.mkdir(parents=True, exist_ok=True)
bat_path = wrapper_dir / "mybot.bat"
bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0, stdout=str(bat_path),
)
result = check_alias_collision("mybot")
assert result is None # our own wrapper, safe to overwrite
# ===================================================================
# TestWrapperScript
# ===================================================================
class TestWrapperScript:
"""Tests for create_wrapper_script() and remove_wrapper_script()."""
def test_creates_sh_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.name == "mybot"
content = wrapper.read_text()
assert content.startswith("#!/bin/sh")
assert "hermes -p mybot" in content
def test_creates_bat_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.name == "mybot.bat"
content = wrapper.read_text()
assert "@echo off" in content
assert "hermes -p mybot" in content
assert "%*" in content
def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.exists()
removed = remove_wrapper_script("mybot")
assert removed is True
assert not wrapper.exists()
def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.exists()
removed = remove_wrapper_script("mybot")
assert removed is True
assert not wrapper.exists()
def test_remove_returns_false_when_absent(self, profile_env):
from hermes_cli.profiles import remove_wrapper_script
assert remove_wrapper_script("nonexistent") is False
def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):
# Custom alias name pointing at a differently-named profile: the file
# is named after the alias, the -p content references the profile.
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("rq", target="redqueen")
assert wrapper is not None
assert wrapper.name == "rq"
content = wrapper.read_text()
assert content.startswith("#!/bin/sh")
assert "hermes -p redqueen" in content
def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):
# Regression: custom-name aliases must still produce an executable
# .bat (not a clobbered #!/bin/sh) on Windows.
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("rq", target="redqueen")
assert wrapper is not None
assert wrapper.name == "rq.bat"
content = wrapper.read_text()
assert "@echo off" in content
assert "hermes -p redqueen" in content
assert "%*" in content
assert "#!/bin/sh" not in content
# ===================================================================
# TestRenameProfile
@@ -793,6 +793,54 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):
assert resolved["model"] == "acme-large"
def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):
"""Named custom providers on one gateway must keep their own credentials and protocol."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("GPT_KEY", "gpt-secret")
monkeypatch.setenv("CLAUDE_KEY", "claude-secret")
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{
"name": "gpt",
"base_url": "https://gateway.example.com",
"key_env": "GPT_KEY",
"api_mode": "codex_responses",
"model": "gpt-5.5",
},
{
"name": "claude",
"base_url": "https://gateway.example.com",
"key_env": "CLAUDE_KEY",
"api_mode": "anthropic_messages",
"model": "claude-opus-4-8",
},
],
},
)
monkeypatch.setattr(
rp,
"resolve_provider",
lambda *a, **k: (_ for _ in ()).throw(
AssertionError(
"resolve_provider should not be called for named custom providers"
)
),
)
resolved = rp.resolve_runtime_provider(requested="custom:claude")
assert resolved["provider"] == "custom"
assert resolved["base_url"] == "https://gateway.example.com"
assert resolved["api_key"] == "claude-secret"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["requested_provider"] == "custom:claude"
assert resolved["model"] == "claude-opus-4-8"
def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
@@ -498,6 +498,7 @@ def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, m
features={
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"),
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
@@ -525,6 +526,7 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat
features={
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"),
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
@@ -88,6 +88,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
features={
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
@@ -0,0 +1,132 @@
"""Tests for hermes_cli.uninstall.remove_node_symlinks.
Regression for #34536: 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 must remove those symlinks, but
only when they still resolve into the Hermes-managed node dir.
"""
import os
from pathlib import Path
import pytest
import hermes_cli.uninstall as uninstall
@pytest.fixture
def fake_home(tmp_path, monkeypatch):
"""Redirect Path.home() at the home both the installer-symlink target and
the ~/.local/bin links live under the same temp dir."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
(home / ".local" / "bin").mkdir(parents=True)
return home
def _make_hermes_node(hermes_home: Path) -> Path:
"""Create a fake $HERMES_HOME/node/bin/{node,npm,npx} tree."""
node_bin = hermes_home / "node" / "bin"
node_bin.mkdir(parents=True)
for name in ("node", "npm", "npx"):
(node_bin / name).write_text("#!/bin/sh\n")
(node_bin / name).chmod(0o755)
return node_bin
def test_removes_symlinks_pointing_into_hermes_node(fake_home):
hermes_home = fake_home / ".hermes"
node_bin = _make_hermes_node(hermes_home)
local_bin = fake_home / ".local" / "bin"
for name in ("node", "npm", "npx"):
(local_bin / name).symlink_to(node_bin / name)
removed = uninstall.remove_node_symlinks(hermes_home)
assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
for name in ("node", "npm", "npx"):
assert not (local_bin / name).exists()
assert not (local_bin / name).is_symlink()
def test_leaves_unrelated_symlinks_untouched(fake_home):
"""A node symlink the user repointed at nvm must survive uninstall."""
hermes_home = fake_home / ".hermes"
_make_hermes_node(hermes_home)
local_bin = fake_home / ".local" / "bin"
# Simulate nvm's node living elsewhere; user's ~/.local/bin/node -> nvm.
nvm_bin = fake_home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin"
nvm_bin.mkdir(parents=True)
(nvm_bin / "node").write_text("#!/bin/sh\n")
(local_bin / "node").symlink_to(nvm_bin / "node")
removed = uninstall.remove_node_symlinks(hermes_home)
assert removed == []
assert (local_bin / "node").is_symlink()
assert (local_bin / "node").resolve() == (nvm_bin / "node").resolve()
def test_leaves_real_binaries_untouched(fake_home):
"""A real (non-symlink) binary in ~/.local/bin is never deleted."""
hermes_home = fake_home / ".hermes"
_make_hermes_node(hermes_home)
local_bin = fake_home / ".local" / "bin"
real_node = local_bin / "node"
real_node.write_text("#!/bin/sh\necho real\n")
real_node.chmod(0o755)
removed = uninstall.remove_node_symlinks(hermes_home)
assert removed == []
assert real_node.exists()
assert not real_node.is_symlink()
def test_handles_missing_local_bin(fake_home):
"""No symlinks present -> no-op, no error."""
hermes_home = fake_home / ".hermes"
_make_hermes_node(hermes_home)
assert uninstall.remove_node_symlinks(hermes_home) == []
def test_removes_dangling_symlink_into_hermes_node(fake_home):
"""A link into the Hermes node dir is removed even if the target file is
already gone (dangling) \u2014 the link still shadows PATH."""
hermes_home = fake_home / ".hermes"
node_bin = hermes_home / "node" / "bin"
node_bin.mkdir(parents=True)
local_bin = fake_home / ".local" / "bin"
# Create the symlink, then delete the target so it dangles.
(local_bin / "node").symlink_to(node_bin / "node")
assert (local_bin / "node").is_symlink()
removed = uninstall.remove_node_symlinks(hermes_home)
assert [p.name for p in removed] == ["node"]
assert not (local_bin / "node").is_symlink()
def test_only_some_links_present(fake_home):
"""Removes the Hermes links that exist; ignores the ones that don't."""
hermes_home = fake_home / ".hermes"
node_bin = _make_hermes_node(hermes_home)
local_bin = fake_home / ".local" / "bin"
# Only npm and npx are Hermes-managed; node is a real user binary.
(local_bin / "npm").symlink_to(node_bin / "npm")
(local_bin / "npx").symlink_to(node_bin / "npx")
(local_bin / "node").write_text("#!/bin/sh\n")
removed = uninstall.remove_node_symlinks(hermes_home)
assert sorted(p.name for p in removed) == ["npm", "npx"]
assert (local_bin / "node").exists()
assert not (local_bin / "npm").is_symlink()
assert not (local_bin / "npx").is_symlink()
+39 -1
View File
@@ -19,6 +19,7 @@ def test_version_string_no_v_prefix():
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
"""When cache is fresh, check_for_updates should return cached value without calling git."""
from hermes_cli.banner import check_for_updates
from hermes_cli import __version__
# Create a fake git repo and fresh cache
repo_dir = tmp_path / "hermes-agent"
@@ -26,7 +27,7 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
(repo_dir / ".git").mkdir()
cache_file = tmp_path / ".update_check"
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("hermes_cli.banner.subprocess.run") as mock_run:
@@ -36,6 +37,43 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
mock_run.assert_not_called()
def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
"""A fresh cache from a different installed version must be re-checked, not reused.
Regression for #34491: after `pip install --upgrade`, VERSION changes but the
cache's 6h TTL hadn't expired and rev was unchanged (both None), so the stale
'behind' count survived the upgrade. The version guard forces a recheck.
"""
import hermes_cli.banner as banner
# No local git checkout -> the PyPI path is exercised (pip-install class).
fake_banner = tmp_path / "hermes_cli" / "banner.py"
fake_banner.parent.mkdir(parents=True, exist_ok=True)
fake_banner.touch()
monkeypatch.setattr(banner, "__file__", str(fake_banner))
# Fresh (within TTL) cache that says "behind", but stamped with an OLD version.
cache_file = tmp_path / ".update_check"
cache_file.write_text(
json.dumps({"ts": time.time(), "behind": 1, "rev": None, "ver": "0.0.1-old"})
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi:
result = banner.check_for_updates()
# Stale-version cache rejected -> fresh check ran -> up-to-date result.
assert result == 0
mock_pypi.assert_called_once()
mock_run.assert_not_called()
# Cache rewritten with the current installed version.
written = json.loads(cache_file.read_text())
assert written["ver"] == banner.VERSION
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
"""When cache is expired, check_for_updates should call git fetch."""
from hermes_cli.banner import check_for_updates
+31 -3
View File
@@ -129,12 +129,40 @@ class TestGuessCategory:
def test_cron_subtree_categorised(self, _isolate_env):
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
p = cron_dir / "job_output.md"
# Only files under ``cron/output/`` are disposable run artifacts.
output_dir = _isolate_env / "cron" / "output" / "job_123"
output_dir.mkdir(parents=True)
p = output_dir / "run.md"
p.write_text("x")
assert dg.guess_category(p) == "cron-output"
def test_cron_jobs_json_not_tracked(self, _isolate_env):
"""Regression for #32164: the cron registry must never be tracked."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
p = cron_dir / "jobs.json"
p.write_text("[]")
assert dg.guess_category(p) is None
def test_cron_tick_lock_not_tracked(self, _isolate_env):
"""Regression for #32164: cron tick-lock is control-plane state."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
p = cron_dir / ".tick.lock"
p.write_text("")
assert dg.guess_category(p) is None
def test_cronjobs_top_level_not_tracked(self, _isolate_env):
"""The legacy ``cronjobs`` alias is also control-plane at the top."""
dg = _load_lib()
cron_dir = _isolate_env / "cronjobs"
cron_dir.mkdir()
p = cron_dir / "jobs.json"
p.write_text("[]")
assert dg.guess_category(p) is None
def test_ordinary_file_returns_none(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "notes.md"
+33 -5
View File
@@ -85,44 +85,72 @@ def test_fal_list_models_advertises_both_modalities():
def test_fal_unavailable_without_key(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
# Also ensure managed gateway is unavailable
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
assert FALVideoGenProvider().is_available() is False
def test_fal_generate_requires_fal_key(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
# Also ensure managed gateway is unavailable
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
result = FALVideoGenProvider().generate("a happy dog")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_fal_available_via_gateway(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(
fal_plugin,
"_resolve_managed_fal_video_gateway",
lambda: object(), # truthy sentinel — gateway is available
)
assert FALVideoGenProvider().is_available() is True
class TestFamilyRouting:
"""The headline behavior: image_url presence picks the endpoint."""
@pytest.fixture
def with_fake_fal(self, monkeypatch):
"""Stub fal_client.subscribe to capture which endpoint we hit."""
"""Stub fal_client.submit to capture which endpoint we hit."""
import sys
import types
captured = {"endpoint": None, "arguments": None}
class FakeHandle:
def get(self):
return {"video": {"url": "https://fake/out.mp4"}}
fake = types.ModuleType("fal_client")
def _subscribe(endpoint, arguments=None, with_logs=False):
def _submit(endpoint, arguments=None, headers=None):
captured["endpoint"] = endpoint
captured["arguments"] = arguments
return {"video": {"url": "https://fake/out.mp4"}}
fake.subscribe = _subscribe # type: ignore
return FakeHandle()
fake.submit = _submit # type: ignore
monkeypatch.setitem(sys.modules, "fal_client", fake)
# Reset the lazy global so it picks up our stub
from plugins.video_gen import fal as fal_plugin
fal_plugin._fal_client = None
# Also reset the managed client cache
fal_plugin._managed_fal_video_client = None
fal_plugin._managed_fal_video_client_config = None
monkeypatch.setenv("FAL_KEY", "test")
# Force direct mode — no managed gateway
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
return captured
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
@@ -229,7 +257,7 @@ class TestPayloadBuilder:
seed=42,
)
assert p["prompt"] == "x"
assert p["duration"] == "8" # FAL queue API uses strings
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
assert p["aspect_ratio"] == "16:9"
assert p["resolution"] == "720p"
assert p["generate_audio"] is True
+158
View File
@@ -491,6 +491,96 @@ class TestPreflightCompression:
for ev, msg in status_messages
)
def test_preflight_defers_when_recent_real_usage_fit(self, agent):
"""A noisy rough estimate should not re-compact a recently fitting request."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 100_000
agent.context_compressor.last_prompt_tokens = 58_000
agent.context_compressor.last_real_prompt_tokens = 58_000
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded"})
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
ok_resp = _mock_response(
content="Used real fit",
finish_reason="stop",
usage={"prompt_tokens": 59_000, "completion_tokens": 100, "total_tokens": 59_100},
)
agent.client.chat.completions.create.side_effect = [ok_resp]
status_messages = []
agent.status_callback = lambda ev, msg: status_messages.append((ev, msg))
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=114_000),
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=big_history)
mock_compress.assert_not_called()
assert result["completed"] is True
assert result["final_response"] == "Used real fit"
assert not any(
ev == "lifecycle" and "Preflight compression" in msg
for ev, msg in status_messages
)
def test_preflight_compresses_when_rough_growth_after_fit_is_large(self, agent):
"""Large rough growth after a fitting request still triggers preflight."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 100_000
agent.context_compressor.last_prompt_tokens = 58_000
agent.context_compressor.last_real_prompt_tokens = 58_000
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded"})
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
ok_resp = _mock_response(
content="Compressed after growth",
finish_reason="stop",
usage={"prompt_tokens": 50_000, "completion_tokens": 100, "total_tokens": 50_100},
)
agent.client.chat.completions.create.side_effect = [ok_resp]
# First rough estimate must clear the threshold so preflight fires
# (rough growth since the last fitting request is large, so the
# deferral path is NOT taken). Every estimate after compaction is
# sub-threshold. Use a callable side_effect rather than a fixed list
# so we don't have to predict how many times the loop re-estimates —
# the post-response real-token estimate is an extra call that a
# 2-element list would exhaust (StopIteration).
_rough_calls = {"n": 0}
def _rough_estimate(*_args, **_kwargs):
_rough_calls["n"] += 1
return 125_000 if _rough_calls["n"] == 1 else 40_000
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate),
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
mock_compress.return_value = (
[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
"new system prompt",
)
result = agent.run_conversation("hello", conversation_history=big_history)
mock_compress.assert_called_once()
assert result["completed"] is True
def test_no_preflight_when_under_threshold(self, agent):
"""When history fits within context, no preflight compression needed."""
agent.compression_enabled = True
@@ -575,6 +665,74 @@ class TestPreflightCompression:
mock_compress.assert_not_called()
assert result["completed"] is True
def test_preflight_seeds_display_tokens_when_compression_aborts(self, agent):
"""Display must reflect the real context size even when compression no-ops.
Regression: the CLI status bar reads ``last_prompt_tokens``, which only
updated from a *successful* API response. When the loaded history was
oversized but compression failed to reduce it (e.g. the auxiliary
summary model timed out), the bar stayed stuck at the old, smaller
value while the preflight estimate reported a much larger number
looking permanently out of sync.
"""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 130_000
# Simulate a stale display value from an earlier, smaller turn.
agent.context_compressor.last_prompt_tokens = 74_400
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded text"})
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [ok_resp]
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
# Compression no-ops (returns input unchanged) — mirrors an aux
# summary-model timeout where the messages can't be reduced.
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hello", conversation_history=big_history)
assert result["completed"] is True
# The display token count was revised up to the fresh preflight estimate,
# not left at the stale 74_400.
assert agent.context_compressor.last_prompt_tokens == 144_669
def test_preflight_seed_only_revises_upward(self, agent):
"""A larger tracked value must not be clobbered by a smaller estimate."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 130_000
# A real, larger usage figure is already tracked.
agent.context_compressor.last_prompt_tokens = 160_000
big_history = []
for i in range(20):
big_history.append({"role": "user", "content": f"Message {i} padded text"})
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [ok_resp]
with (
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
agent.run_conversation("hello", conversation_history=big_history)
# Smaller estimate must not overwrite the larger tracked value.
assert agent.context_compressor.last_prompt_tokens == 160_000
class TestToolResultPreflightCompression:
"""Compression should trigger when tool results push context past the threshold."""
+6 -1
View File
@@ -70,4 +70,9 @@ def test_tool_call_validation_accepts_dict_arguments(monkeypatch):
result = agent.run_conversation("read the file")
assert result["final_response"] == "done"
# The conversation hits max_iterations=3 (3 tool turns then forced summary).
# PR #34470 adds an explainer suffix to abnormal turn endings so users
# understand why the response is short instead of seeing a blank reply.
# The exact suffix wording is owned by conversation_loop; this test only
# cares that the model's actual text ('done') survives at the start.
assert result["final_response"].startswith("done")
+18 -5
View File
@@ -3046,7 +3046,11 @@ class TestRunConversation:
mock_compress.assert_not_called() # no compression triggered
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: the bare "(empty)" sentinel is now replaced by a
# user-visible end-of-turn explanation so the failure isn't silent.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["turn_exit_reason"] == "empty_response_exhausted"
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
def test_reasoning_only_response_prefill_then_empty(self, agent):
@@ -3066,7 +3070,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
def test_reasoning_only_prefill_succeeds_on_continuation(self, agent):
@@ -3113,7 +3119,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
assert result["api_calls"] == 4 # 1 original + 3 retries
def test_truly_empty_response_succeeds_on_nudge(self, agent):
@@ -3209,7 +3217,9 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
def test_empty_response_emits_status_for_gateway(self, agent):
"""_emit_status is called during empty retries so gateway users see feedback."""
@@ -3235,7 +3245,10 @@ class TestRunConversation:
):
result = agent.run_conversation("answer me")
assert result["final_response"] == "(empty)"
# #34452: explanation replaces the bare "(empty)" sentinel, but the
# status emissions during retries are unchanged.
assert result["final_response"] != "(empty)"
assert "No reply:" in result["final_response"]
# Should have emitted retry statuses (3 retries) + final failure
retry_msgs = [m for m in status_messages if "retrying" in m.lower()]
assert len(retry_msgs) == 3, f"Expected 3 retry status messages, got {len(retry_msgs)}: {status_messages}"
@@ -0,0 +1,181 @@
"""Tests for the end-of-turn completion explainer (#34452).
When a turn ends abnormally after tools (empty content after retries, a
partial/truncated stream, exhausted retries, or an iteration/budget limit)
the user should get a single user-visible explanation of why the reply
stopped instead of a blank or fragmentary response box. Normal short
replies (e.g. ``Done.``) must stay quiet.
These tests exercise:
1. ``_format_turn_completion_explanation`` the pure reasonmessage map.
2. ``_turn_completion_explainer_enabled`` the env/config seam.
3. An end-to-end ``run_conversation`` turn that exhausts empty-response
retries and verifies the explanation reaches ``final_response``.
All assertions work under the mocked OpenAI SDK used elsewhere in this
suite (we patch ``run_agent.OpenAI`` and drive ``agent.client``), so they
pass identically in CI and locally.
"""
import os
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
# --------------------------------------------------------------------------
# Fixtures (mirrors tests/run_agent/test_tool_call_guardrail_runtime.py)
# --------------------------------------------------------------------------
def _mock_response(content="Hello", finish_reason="stop", tool_calls=None):
msg = SimpleNamespace(content=content, tool_calls=tool_calls)
choice = SimpleNamespace(message=msg, finish_reason=finish_reason)
return SimpleNamespace(choices=[choice], model="test/model", usage=None)
def _make_agent(max_iterations: int = 10, config: dict | None = None) -> AIAgent:
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("hermes_cli.config.load_config", return_value=config or {}),
patch("run_agent.OpenAI"),
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
max_iterations=max_iterations,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent.client = MagicMock()
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.tool_delay = 0
agent.compression_enabled = False
agent.save_trajectories = False
# No fallback chain so empty responses exhaust deterministically.
agent._fallback_chain = []
return agent
# --------------------------------------------------------------------------
# 1. Pure formatter
# --------------------------------------------------------------------------
def test_explanation_quiet_for_normal_text_response():
"""A healthy text_response exit must NOT produce any explanation."""
out = AIAgent._format_turn_completion_explanation(
"text_response(finish_reason=stop)"
)
assert out == ""
def test_explanation_quiet_for_empty_reason():
assert AIAgent._format_turn_completion_explanation("") == ""
assert AIAgent._format_turn_completion_explanation("unknown") == ""
# guardrail_halt surfaces its own message; explainer stays out of the way.
assert AIAgent._format_turn_completion_explanation("guardrail_halt") == ""
def test_explanation_for_empty_response_exhausted():
out = AIAgent._format_turn_completion_explanation("empty_response_exhausted")
assert out # non-empty
assert "empty content" in out
assert "continue" in out.lower()
def test_explanation_for_partial_stream_recovery():
out = AIAgent._format_turn_completion_explanation("partial_stream_recovery")
assert "partial" in out.lower()
assert "continue" in out.lower()
def test_explanation_for_max_iterations_reached_prefix_match():
"""``max_iterations_reached(...)`` carries a parenthetical suffix."""
out = AIAgent._format_turn_completion_explanation(
"max_iterations_reached(10/10)"
)
assert "iteration" in out.lower()
def test_explanation_for_all_retries_exhausted():
out = AIAgent._format_turn_completion_explanation(
"all_retries_exhausted_no_response"
)
assert "retries" in out.lower()
# --------------------------------------------------------------------------
# 2. Enable/disable seam
# --------------------------------------------------------------------------
def test_explainer_enabled_by_default():
agent = _make_agent()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
with patch("hermes_cli.config.load_config", return_value={}):
assert agent._turn_completion_explainer_enabled() is True
def test_explainer_disabled_via_env():
agent = _make_agent()
with patch.dict(
os.environ, {"HERMES_TURN_COMPLETION_EXPLAINER": "0"}, clear=False
):
assert agent._turn_completion_explainer_enabled() is False
def test_explainer_disabled_via_config():
agent = _make_agent()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
with patch(
"hermes_cli.config.load_config",
return_value={"display": {"turn_completion_explainer": False}},
):
assert agent._turn_completion_explainer_enabled() is False
# --------------------------------------------------------------------------
# 3. End-to-end: empty-response exhaustion surfaces the explanation
# --------------------------------------------------------------------------
def test_run_conversation_empty_exhausted_surfaces_explanation():
"""Four empty responses in a row should exhaust retries and the final
response should be the actionable explanation, not a bare '(empty)'."""
agent = _make_agent(max_iterations=10)
# 4 empty responses: retries 1..3 then the terminal on the 4th.
agent.client.chat.completions.create.side_effect = [
_mock_response(content="", finish_reason="stop") for _ in range(8)
]
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("do something")
assert result["turn_exit_reason"] == "empty_response_exhausted"
# The user must NOT be left with a bare sentinel; the explanation wins.
assert result["final_response"] != "(empty)"
assert result["final_response"].strip() != ""
assert "No reply:" in result["final_response"]
def test_run_conversation_normal_reply_stays_quiet():
"""A normal short reply like 'Done.' must NOT get an explainer footer."""
agent = _make_agent(max_iterations=10)
agent.client.chat.completions.create.side_effect = [
_mock_response(content="Done.", finish_reason="stop"),
]
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("do something")
assert result["turn_exit_reason"].startswith("text_response")
assert result["final_response"] == "Done."
assert "No reply:" not in result["final_response"]
+58
View File
@@ -2676,6 +2676,64 @@ class TestVacuum:
db.vacuum()
class TestOptimizeFts:
def test_optimize_returns_index_count(self, db):
"""A fresh DB has both FTS indexes; optimize merges both."""
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="hello world")
assert db.optimize_fts() == 2
def test_optimize_preserves_search_and_snippet(self, db):
"""Optimize is layout-only: MATCH results + snippets are unchanged."""
db.create_session(session_id="s1", source="cli")
for i in range(50):
db.append_message(
session_id="s1",
role="user",
content=f"needle alpha bravo charlie message {i}",
)
before = db.search_messages("needle")
n = db.optimize_fts()
assert n == 2
after = db.search_messages("needle")
assert len(after) == len(before)
assert len(after) > 0
# Snippet must still be populated (would be empty/None if the FTS
# content shadow were lost during optimize).
assert all(row.get("snippet") for row in after)
# IDs and snippets are identical before/after — pure layout change.
assert [r["id"] for r in after] == [r["id"] for r in before]
assert [r["snippet"] for r in after] == [r["snippet"] for r in before]
def test_optimize_skips_missing_trigram_table(self, db):
"""When the trigram index is absent, optimize handles only the porter
index and does not raise."""
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="hello")
# Drop the trigram table + triggers to simulate a disabled/absent index.
with db._lock:
for trig in (
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
db._conn.execute(f"DROP TRIGGER IF EXISTS {trig}")
db._conn.execute("DROP TABLE IF EXISTS messages_fts_trigram")
assert db._fts_table_exists("messages_fts_trigram") is False
assert db._fts_table_exists("messages_fts") is True
# Only the porter index remains -> 1 optimized, no error.
assert db.optimize_fts() == 1
def test_optimize_idempotent(self, db):
"""Running optimize twice is safe (second pass is a no-op merge)."""
db.create_session(session_id="s1", source="cli")
db.append_message(session_id="s1", role="user", content="repeat me")
assert db.optimize_fts() == 2
assert db.optimize_fts() == 2
# Search still works after repeated optimization.
assert len(db.search_messages("repeat")) == 1
class TestAutoMaintenance:
def _make_old_ended(self, db, sid: str, days_old: int = 100):
"""Create a session that is ended and was started `days_old` days ago."""
+56
View File
@@ -1,10 +1,66 @@
from pathlib import Path
import tomllib
import pytest
# setuptools is declared in the [dev] extra and is the build backend, but
# guard the import so a runner without it skips these packaging checks
# instead of erroring out collection for the whole shard (it used to be
# picked up ambiently from the CI image; newer ubuntu-latest images don't
# ship it in the test venv).
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
REPO_ROOT = Path(__file__).resolve().parents[1]
def _packages_find_include():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
return data["tool"]["setuptools"]["packages"]["find"]["include"]
def test_every_on_disk_subpackage_is_covered_by_packages_find():
"""Regression test for #34701 (and the bug class behind #34034 / #28149).
``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every
top-level package is listed twice bare (``hermes_cli``) for the package
itself and ``hermes_cli.*`` for its subpackages EXCEPT when someone
forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``,
so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth``
and ``proxy`` subpackages. The dashboard then died on every install with
``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``.
This drives setuptools' own discovery against the live tree: every package
that exists on disk and would be found by a permissive ``<name>.*`` scan
must also be found by the actual ``include`` list. A subpackage added under
any listed package without the matching wildcard fails here instead of in a
user's container.
"""
include = _packages_find_include()
# What the real include list actually selects.
selected = set(find_packages(where=str(REPO_ROOT), include=include))
# Top-level packages we ship (bare names in the include list, no wildcard).
top_level = sorted({name for name in include if "." not in name})
# For each shipped top-level package, every on-disk subpackage must be
# covered by the include list.
expected = set(
find_packages(
where=str(REPO_ROOT),
include=[pattern for name in top_level for pattern in (name, f"{name}.*")],
)
)
missing = sorted(expected - selected)
assert not missing, (
"These packages exist on disk but are dropped from the wheel because "
"[tool.setuptools.packages.find] include is missing a wildcard. Add the "
f"matching '<name>.*' entry in pyproject.toml: {missing}"
)
def test_faster_whisper_is_not_a_base_dependency():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["dependencies"]
+1
View File
@@ -73,6 +73,7 @@ def test_lazy_installable_extras_excluded_from_all():
"modal", "daytona",
"messaging", "slack", "matrix", "dingtalk", "feishu",
"honcho", "hindsight",
"mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral)
}
all_extra_specs = optional_dependencies["all"]
for extra in lazy_covered_extras:
+12 -7
View File
@@ -5114,6 +5114,8 @@ def test_notification_poller_skips_consumed(monkeypatch):
def test_notification_poller_requeues_when_busy(monkeypatch):
"""When the agent is busy, the poller requeues the event."""
import queue as _queue_mod
from tools.process_registry import process_registry
emitted = []
@@ -5122,8 +5124,13 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
server._sessions["sid_busy"] = sess
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
# Isolate the completion queue for the duration of this test. The poller
# reads process_registry.completion_queue by attribute at runtime, so a
# fresh Queue here means no concurrently-running test in the same xdist
# worker can put/get on the shared singleton mid-run and drain the event
# we expect to be requeued. monkeypatch restores the original on teardown.
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
process_registry._completion_consumed.discard("proc_busy_test")
evt = {
@@ -5133,7 +5140,7 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
"exit_code": 0,
"output": "ok",
}
process_registry.completion_queue.put(evt)
isolated_queue.put(evt)
stop = threading.Event()
stop.set()
@@ -5146,10 +5153,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
assert len(status_calls) == 1
# Event was requeued (agent was busy, no turn triggered)
assert not process_registry.completion_queue.empty()
requeued = process_registry.completion_queue.get_nowait()
assert not isolated_queue.empty()
requeued = isolated_queue.get_nowait()
assert requeued["session_id"] == "proc_busy_test"
finally:
server._sessions.pop("sid_busy", None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
+211
View File
@@ -305,3 +305,214 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
assert json_result["transcript"] == "hello from gpt-4o"
assert json_capture["transcription_kwargs"]["response_format"] == "json"
assert json_capture["close_calls"] == 1
PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
def _load_video_gen_plugin(monkeypatch):
"""Load the FAL video gen plugin in isolation."""
_install_fake_tools_package()
# Also need the agent.video_gen_provider ABC
agent_dir = Path(__file__).resolve().parents[2] / "agent"
spec = spec_from_file_location(
"agent.video_gen_provider",
agent_dir / "video_gen_provider.py",
)
assert spec and spec.loader
mod = module_from_spec(spec)
sys.modules["agent.video_gen_provider"] = mod
spec.loader.exec_module(mod)
# Load the plugin
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
assert spec and spec.loader
plugin_mod = module_from_spec(spec)
sys.modules["plugins.video_gen.fal"] = plugin_mod
spec.loader.exec_module(plugin_mod)
return plugin_mod
def test_video_gen_managed_fal_submit_uses_gateway(monkeypatch):
"""Video gen routes through the managed gateway when FAL_KEY is absent."""
captured = {}
fake_fal = _install_fake_fal_client(captured)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
plugin = _load_video_gen_plugin(monkeypatch)
# Patch uuid for deterministic idempotency key
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "video-submit-456")
plugin._submit_fal_video_request(
"fal-ai/pixverse/v6/text-to-video",
{"prompt": "a cat riding a bicycle", "duration": "5"},
)
assert captured["submit_via"] == "managed_client"
assert captured["client_key"] == "nous-video-token"
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/pixverse/v6/text-to-video"
assert captured["method"] == "POST"
assert captured["arguments"] == {"prompt": "a cat riding a bicycle", "duration": "5"}
assert captured["headers"] == {"x-idempotency-key": "video-submit-456"}
assert captured["sync_client_inits"] == 1
def test_video_gen_managed_client_reused_across_calls(monkeypatch):
"""The managed video client is cached and reused across requests."""
captured = {}
_install_fake_fal_client(captured)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
plugin = _load_video_gen_plugin(monkeypatch)
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "first"})
first_client = captured["http_client"]
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "second"})
assert captured["sync_client_inits"] == 1
assert captured["http_client"] is first_client
def test_video_gen_direct_mode_when_fal_key_set(monkeypatch):
"""When FAL_KEY is set and gateway not preferred, uses direct fal_client.submit."""
captured = {}
_install_fake_fal_client(captured)
monkeypatch.setenv("FAL_KEY", "direct-fal-key-123")
monkeypatch.delenv("FAL_QUEUE_GATEWAY_URL", raising=False)
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
plugin = _load_video_gen_plugin(monkeypatch)
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "direct-456")
# Trigger the lazy load so _fal_client is populated from our fake
plugin._load_fal_client()
# In direct mode, fal_client.submit is the module-level function.
# Our fake raises AssertionError from the managed path, so we need
# to patch it to actually capture the call.
direct_captured = {}
def direct_submit(endpoint, arguments=None, headers=None):
direct_captured["endpoint"] = endpoint
direct_captured["arguments"] = arguments
direct_captured["headers"] = headers
# Return a mock handle
class FakeHandle:
def get(self):
return {"video": {"url": "https://fal.media/result.mp4"}}
return FakeHandle()
plugin._fal_client.submit = direct_submit
plugin._submit_fal_video_request(
"fal-ai/pixverse/v6/text-to-video",
{"prompt": "test direct"},
)
assert direct_captured["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
assert direct_captured["arguments"] == {"prompt": "test direct"}
assert direct_captured["headers"] == {"x-idempotency-key": "direct-456"}
# Managed client should NOT have been initialized
assert "submit_via" not in captured
def test_video_gen_gateway_4xx_raises_actionable_valueerror(monkeypatch):
"""A 4xx from the managed gateway surfaces a clear ValueError with remediation hints."""
captured = {}
_install_fake_fal_client(captured)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
plugin = _load_video_gen_plugin(monkeypatch)
# Make _maybe_retry_request raise an exception with a 403 status
class FakeResponse:
status_code = 403
class GatewayRejectError(Exception):
def __init__(self):
super().__init__("forbidden")
self.response = FakeResponse()
original_retry = sys.modules["fal_client"].client._maybe_retry_request
def raising_retry(client, method, url, json=None, timeout=None, headers=None):
raise GatewayRejectError()
sys.modules["fal_client"].client._maybe_retry_request = raising_retry
with pytest.raises(ValueError, match=r"gateway rejected endpoint.*HTTP 403"):
plugin._submit_fal_video_request(
"fal-ai/pixverse/v6/text-to-video",
{"prompt": "test 4xx"},
)
def test_video_gen_is_available_true_via_gateway(monkeypatch):
"""is_available() returns True when FAL_KEY is absent but managed gateway is configured."""
_install_fake_fal_client({})
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
plugin = _load_video_gen_plugin(monkeypatch)
provider = plugin.FALVideoGenProvider()
assert provider.is_available() is True
def test_video_gen_prefers_gateway_overrides_direct_key(monkeypatch):
"""When FAL_KEY is set but prefers_gateway('video_gen') is True, routes through gateway."""
captured = {}
_install_fake_fal_client(captured)
monkeypatch.setenv("FAL_KEY", "direct-key-present")
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
plugin = _load_video_gen_plugin(monkeypatch)
# Patch prefers_gateway to return True for video_gen
tb_helpers = sys.modules["tools.tool_backend_helpers"]
original_pg = tb_helpers.prefers_gateway
monkeypatch.setattr(tb_helpers, "prefers_gateway", lambda section: section == "video_gen")
plugin._submit_fal_video_request(
"fal-ai/pixverse/v6/text-to-video",
{"prompt": "gateway preferred"},
)
assert captured["submit_via"] == "managed_client"
assert captured["client_key"] == "nous-video-token"
def test_video_gen_happy_horse_uses_alibaba_namespace():
"""Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints."""
_install_fake_tools_package()
# Load just the plugin module to check the catalog
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
agent_dir = Path(__file__).resolve().parents[2] / "agent"
spec = spec_from_file_location(
"agent.video_gen_provider",
agent_dir / "video_gen_provider.py",
)
mod = module_from_spec(spec)
sys.modules["agent.video_gen_provider"] = mod
spec.loader.exec_module(mod)
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
plugin_mod = module_from_spec(spec)
sys.modules["plugins.video_gen.fal"] = plugin_mod
spec.loader.exec_module(plugin_mod)
hh = plugin_mod.FAL_FAMILIES["happy-horse"]
assert hh["text_endpoint"] == "alibaba/happy-horse/text-to-video"
assert hh["image_endpoint"] == "alibaba/happy-horse/image-to-video"
+8
View File
@@ -50,6 +50,14 @@ class TestResolveTrustLevel:
assert _resolve_trust_level("anthropics/skills") == "trusted"
assert _resolve_trust_level("openai/skills/some-skill") == "trusted"
def test_nvidia_skills_is_trusted(self):
# NVIDIA/skills ships NVIDIA-verified skills with detached OMS
# signatures and governance skill cards. It's wired through the
# same trust path as the OpenAI / Anthropic / HuggingFace taps.
assert _resolve_trust_level("NVIDIA/skills") == "trusted"
assert _resolve_trust_level("NVIDIA/skills/aiq-deploy") == "trusted"
assert _resolve_trust_level("skills-sh/NVIDIA/skills/cuopt") == "trusted"
def test_trusted_repo_sibling_prefixes_are_not_trusted(self):
assert _resolve_trust_level("openai/skills-evil") == "community"
assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community"
+167
View File
@@ -70,6 +70,143 @@ class TestParseFrontmatterQuick:
assert fm == {}
# ---------------------------------------------------------------------------
# GitHubSource skills.sh.json grouping sidecar (category support)
# ---------------------------------------------------------------------------
class TestSkillsShGroupings:
"""Parsing + stamping of the skills.sh.json grouping sidecar.
A tap can ship a repo-root ``skills.sh.json`` declaring category
groupings; we flatten it to {skill_name: title} and stamp the title onto
each SkillMeta's ``extra["category"]``. This is the generic cross-ecosystem
mechanism behind NVIDIA-style categorization not NVIDIA-specific.
"""
def test_parse_basic_groupings(self):
content = json.dumps({
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
"groupings": [
{"title": "Inference AI", "skills": ["dynamo-router", "dynamo-recipe"]},
{"title": "Decision Optimization", "skills": ["cuopt-developer"]},
],
})
mapping = GitHubSource._parse_skillsh_groupings(content)
assert mapping == {
"dynamo-router": "Inference AI",
"dynamo-recipe": "Inference AI",
"cuopt-developer": "Decision Optimization",
}
def test_parse_invalid_json_returns_none(self):
assert GitHubSource._parse_skillsh_groupings("not json{{") is None
def test_parse_non_dict_returns_none(self):
assert GitHubSource._parse_skillsh_groupings("[1, 2, 3]") is None
def test_parse_missing_groupings_returns_none(self):
assert GitHubSource._parse_skillsh_groupings('{"foo": 1}') is None
def test_parse_empty_groupings_returns_empty_map(self):
assert GitHubSource._parse_skillsh_groupings('{"groupings": []}') == {}
def test_parse_tolerates_malformed_group(self):
# A group missing its skills list is skipped; the valid one survives.
content = json.dumps({"groupings": [
{"title": "X"}, # no skills -> skipped
{"skills": ["a"]}, # no title -> skipped
{"title": "Y", "skills": ["b", 5, None]}, # only valid string members kept
]})
assert GitHubSource._parse_skillsh_groupings(content) == {"b": "Y"}
def test_parse_first_grouping_wins_on_duplicate(self):
content = json.dumps({"groupings": [
{"title": "First", "skills": ["dup"]},
{"title": "Second", "skills": ["dup"]},
]})
assert GitHubSource._parse_skillsh_groupings(content) == {"dup": "First"}
def test_get_groupings_caches_per_repo(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
content = json.dumps({"groupings": [{"title": "T", "skills": ["s"]}]})
with patch.object(src, "_fetch_file_content", return_value=content) as mock_fetch:
first = src._get_skillsh_groupings("acme/skills")
second = src._get_skillsh_groupings("acme/skills")
assert first == {"s": "T"}
assert second == {"s": "T"}
# Second call must hit the per-repo cache, not GitHub again.
mock_fetch.assert_called_once_with("acme/skills", "skills.sh.json")
def test_get_groupings_no_sidecar_returns_none_and_caches(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
with patch.object(src, "_fetch_file_content", return_value=None) as mock_fetch:
assert src._get_skillsh_groupings("acme/skills") is None
assert src._get_skillsh_groupings("acme/skills") is None
mock_fetch.assert_called_once()
def test_list_skills_stamps_category_from_sidecar(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
meta = SkillMeta(
name="cuopt-developer", description="d", source="github",
identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted",
)
contents = [{"type": "dir", "name": "cuopt-developer"}]
groupings = {"cuopt-developer": "Decision Optimization"}
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = contents
with patch.object(src, "_read_cache", return_value=None), \
patch.object(src, "_write_cache"), \
patch.object(src, "_get_skillsh_groupings", return_value=groupings), \
patch.object(src, "inspect", return_value=meta), \
patch("tools.skills_hub.httpx.get", return_value=resp):
skills = src._list_skills_in_repo("NVIDIA/skills", "skills/")
assert len(skills) == 1
assert skills[0].extra["category"] == "Decision Optimization"
def test_list_skills_no_sidecar_leaves_extra_empty(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
meta = SkillMeta(
name="foo", description="d", source="github",
identifier="acme/skills/skills/foo", trust_level="community",
)
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = [{"type": "dir", "name": "foo"}]
with patch.object(src, "_read_cache", return_value=None), \
patch.object(src, "_write_cache"), \
patch.object(src, "_get_skillsh_groupings", return_value=None), \
patch.object(src, "inspect", return_value=meta), \
patch("tools.skills_hub.httpx.get", return_value=resp):
skills = src._list_skills_in_repo("acme/skills", "skills/")
assert len(skills) == 1
assert "category" not in skills[0].extra
def test_meta_to_dict_roundtrip_preserves_extra(self):
meta = SkillMeta(
name="x", description="d", source="github",
identifier="acme/skills/x", trust_level="trusted",
extra={"category": "Inference AI"},
)
d = GitHubSource._meta_to_dict(meta)
assert d["extra"] == {"category": "Inference AI"}
# Round-trips back through the cache deserialization path.
restored = SkillMeta(**d)
assert restored.extra == {"category": "Inference AI"}
# ---------------------------------------------------------------------------
# GitHubSource.trust_level_for
# ---------------------------------------------------------------------------
@@ -102,6 +239,36 @@ class TestTrustLevelFor:
# No path part — still resolves repo correctly
assert result in {"trusted", "community"}
def test_nvidia_skills_tap_is_registered_and_trusted(self):
# Invariant: every trusted repo in TRUSTED_REPOS that we want
# browseable/searchable through `hermes skills browse` must also
# appear as a default tap on GitHubSource. Without the tap, the
# repo's skills don't show up in search results or the docs-site
# Skills Hub page even though the trust level is correct.
from tools.skills_guard import TRUSTED_REPOS
assert "NVIDIA/skills" in TRUSTED_REPOS
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
assert "NVIDIA/skills" in tap_repos
src = self._source()
assert src.trust_level_for("NVIDIA/skills/aiq-deploy") == "trusted"
def test_browseable_trusted_repos_have_taps(self):
# General invariant covering all current and future trusted repos
# that publish under a single `skills/`-style path. openai/skills
# is the deliberate exception — it has two taps (`.curated/` and
# `.system/`) — so we just assert membership not path equality.
from tools.skills_guard import TRUSTED_REPOS
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
for repo in TRUSTED_REPOS:
assert repo in tap_repos, (
f"Trusted repo {repo!r} is in TRUSTED_REPOS but missing "
"from GitHubSource.DEFAULT_TAPS — its skills will not be "
"browsable via `hermes skills browse`."
)
# ---------------------------------------------------------------------------
# SkillsShSource
@@ -99,12 +99,6 @@ class TestProviderSelectionGate:
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
def test_explicit_mistral_sees_dotenv(self):
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
Even with the dotenv key visible, explicit `provider: mistral` must
return "none" with a warning. Restore the previous behavior once
`mistralai` is un-quarantined on PyPI.
"""
from tools import transcription_tools as tt
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
@@ -112,7 +106,7 @@ class TestProviderSelectionGate:
patch.object(tt, "_has_local_command", return_value=False), \
patch("hermes_cli.config.load_env",
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
def test_explicit_xai_sees_dotenv(self):
from tools import transcription_tools as tt

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