Compare commits

..

41 Commits

Author SHA1 Message Date
alt-glitch c71b09be77 Move container detection to hermes_constants
Remove `_is_inside_container()` from `hermes_cli/config.py` and migrate
callers to use `is_container()` from `hermes_constants`. This
centralizes
container environment detection in a single, reusable location.
2026-04-12 13:08:23 -07:00
alt-glitch 2f2eeffb96 Add container detection utility to hermes_constants
Extract `is_container()` detection logic from scattered locations
(`config.py`, `voice_mode.py`) into a centralized, cached function in
`hermes_constants.py`. This follows the same pattern as `is_wsl()` and
`is_termux()` — checking `/.dockerenv`, `/run/.containerenv`, and cgroup
markers.

Update gateway status detection (`status.py`, `dump.py`) to use the new
utility and handle Docker/Podman differently from systemd-based systems.
Update setup guidance (`setup.py`) to show Docker restart instructions
when running in a container.

Add Dockerfile.test for CI integration testing and spec.md as a Python
module taste guide for contributors.
2026-04-12 13:08:15 -07:00
Dilee e89b9d9732 fix(gateway): handle Linux setups without systemctl 2026-04-12 11:58:02 -07:00
Teknium 4eecaf06e4 fix: prevent duplicate update prompt spam in gateway watcher (#8343)
The _watch_update_progress() poll loop never deleted .update_prompt.json
after forwarding the prompt to the user, causing the same prompt to be
re-sent every poll cycle (2s). Two fixes:

1. Delete .update_prompt.json after forwarding — the update process only
   polls for .update_response, it doesn't need the prompt file to persist.
2. Guard re-sends with _update_prompt_pending check — belt-and-suspenders
   to prevent duplicates even under race conditions.

Add regression test asserting the prompt is sent exactly once.
2026-04-12 04:52:59 -07:00
Teknium 7a67b13506 fix: title_generator no longer logs as 'compression' task
Changed task='compression' to task='title_generation' so auto-title
calls don't pollute logs with false compression alarms.
2026-04-12 04:17:18 -07:00
Teknium 45e60904c6 fix: fall back to provider's default model when model config is empty (#8303)
When a user configures a provider (e.g. `hermes auth add openai-codex`)
but never selects a model via `hermes model`, the gateway and CLI would
pass an empty model string to the API, causing:
  'Codex Responses request model must be a non-empty string'

Now both gateway (_resolve_session_agent_runtime) and CLI
(_ensure_runtime_credentials) detect an empty model and fill it from
the provider's first catalog entry in _PROVIDER_MODELS. This covers
all providers that have a static model list (openai-codex, anthropic,
gemini, copilot, etc.).

The fix is conservative: it only triggers when model is truly empty
and a known provider was resolved. Explicit model choices are never
overridden.
2026-04-12 03:53:30 -07:00
Teknium 17c72f176d fix: make skill loading instructions more aggressive in system prompt (#8286)
The previous wording ('If one clearly matches') set too high a threshold,
and 'If none match, proceed normally' was an easy escape hatch for lazy
models. Now:

- Lowered threshold: 'matches or is even partially relevant'
- Added MUST directive and 'err on the side of loading' guidance
- Replaced permissive closer with 'only proceed without if genuinely none
  are relevant'

This should reduce cases where the agent skips loading relevant skills
unless explicitly forced.
2026-04-12 03:03:16 -07:00
Teknium b6b6b02f0f fix: prevent unwanted session auto-reset after graceful gateway restarts (#8299)
When the gateway shuts down gracefully (hermes update, gateway restart,
/restart), it now writes a .clean_shutdown marker file. On the next
startup, if this marker exists, suspend_recently_active() is skipped
and the marker is cleaned up.

Previously, suspend_recently_active() fired on EVERY startup —
including planned restarts from hermes update or hermes gateway restart.
This caused users to lose their conversation history unexpectedly: the
session would be marked as suspended, and the next message would
trigger an auto-reset with a notification the user never asked for.

The original purpose of suspend_recently_active() is crash recovery —
preventing stuck sessions that were mid-processing when the gateway
died unexpectedly. Graceful shutdowns already drain active agents via
_drain_active_agents(), so there is no stuck-session risk. After a
crash (no marker written), suspension still fires as before.

Fixes the scenario where a user asks the agent to run hermes update,
the gateway restarts, and the user's next message gets an unwanted
'Session automatically reset' notification with their history cleared.
2026-04-12 03:03:07 -07:00
Teknium 56e3ee2440 fix: write update exit code before gateway restart (cgroup kill race) (#8288)
When /update runs via Telegram, hermes update --gateway is spawned inside
the gateway's systemd cgroup.  The update process itself calls
systemctl restart hermes-gateway, which tears down the cgroup with
KillMode=mixed — SIGKILL to all remaining processes.  The wrapping bash
shell is killed before it can execute the exit-code epilogue, so
.update_exit_code is never created.  The new gateway's update watcher
then polls for 30 minutes and sends a spurious timeout message.

Fix: write .update_exit_code from Python inside cmd_update() immediately
after the git pull + pip install succeed ("Update complete!"), before
attempting the gateway restart.  The shell epilogue still writes it too
(idempotent overwrite), but now the marker exists even when the process
is killed mid-restart.
2026-04-12 02:33:21 -07:00
Teknium b321330362 feat: add WSL environment hint to system prompt (#8285)
When running inside WSL (Windows Subsystem for Linux), inject a hint into
the system prompt explaining that the Windows host filesystem is mounted
at /mnt/c/, /mnt/d/, etc. This lets the agent naturally translate Windows
paths (Desktop, Documents) to their /mnt/ equivalents without the user
needing to configure anything.

Uses the existing is_wsl() detection from hermes_constants (cached,
checks /proc/version for 'microsoft'). Adds build_environment_hints()
in prompt_builder.py — extensible for Termux, Docker, etc. later.

Closes the UX gap where WSL users had to manually explain path
translation to the agent every session.
2026-04-12 02:26:28 -07:00
Teknium dd5b1063d0 fix: register MATRIX_RECOVERY_KEY env var + document migration path
Follow-up for cherry-picked PR #8272:
- Add MATRIX_RECOVERY_KEY to module docstring header in matrix.py
- Register in OPTIONAL_ENV_VARS (config.py) with password=True, advanced=True
- Add to _NON_SETUP_ENV_VARS set
- Document cross-signing verification in matrix.md E2EE section
- Update migration guide with recovery key step (step 3)
- Add to environment-variables.md reference
2026-04-12 02:18:03 -07:00
elkimek b9af4955b9 fix(matrix): restore verify_with_recovery_key after device key rotation
After the PgCryptoStore migration in v0.8.0, the verify_with_recovery_key
call that previously ran after share_keys() was dropped. On any rotation
that uploads fresh device keys (fresh crypto.db, server had stale keys
from a prior install, etc.), the new device keys carry no valid self-
signing signature because the bot has no access to the self-signing
private key.

Peers like Element then refuse to share Megolm sessions with the
rotated device, so the bot silently stops decrypting incoming messages.

This restores the recovery-key bootstrap: on startup, if
MATRIX_RECOVERY_KEY is set, import the cross-signing private keys from
SSSS and sign_own_device(), producing a valid signature server-side.

Idempotent and gated on MATRIX_RECOVERY_KEY — no behavior change for
users who don't configure a recovery key.

Verified end-to-end by deleting crypto.db and restarting: the bot
rotates device identity keys, re-uploads, self-signs via recovery key,
and decrypts+replies to fresh messages from a paired Element client.
2026-04-12 02:18:03 -07:00
Ben Barclay b0d65c333a Merge pull request #8279 from NousResearch/chore/simplify-docker-tags
chore: simplify Docker image tags
2026-04-12 19:09:05 +10:00
Ben 00adbd0de0 chore: simplify Docker image tags
- Main branch push: only push :latest (remove SHA tag)
- Release push: only push release tag name (remove :latest and SHA tag)
2026-04-12 19:08:16 +10:00
Teknium 95fa78eb6c fix: write refreshed Codex tokens back to ~/.codex/auth.json (#8277)
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When Hermes refreshes a Codex token, it consumed the old refresh_token
but never wrote the new pair back to ~/.codex/auth.json. This caused
Codex CLI and VS Code to fail with 'refresh_token_reused' on their
next refresh attempt.

This mirrors the existing Anthropic write-back pattern where refreshed
tokens are written to ~/.claude/.credentials.json via
_write_claude_code_credentials().

Changes:
- Add _write_codex_cli_tokens() in hermes_cli/auth.py (parallel to
  _write_claude_code_credentials in anthropic_adapter.py)
- Call it from _refresh_codex_auth_tokens() (non-pool refresh path)
- Call it from credential_pool._refresh_entry() (pool happy path + retry)
- Add tests for the new write-back behavior
- Update existing test docstring to clarify _save_codex_tokens vs
  _write_codex_cli_tokens separation

Fixes refresh token conflict reported by @ec12edfae2cb221
2026-04-12 02:05:20 -07:00
Teknium 6d05e3d56f fix(gateway): evict cached agent on /model switch + add diagnostic logging (#8276)
After /model switches the model (both picker and text paths), the cached
agent's config signature becomes stale — the agent was updated in-place
via switch_model() but the cache tuple's signature was never refreshed.
The next turn *should* detect the signature mismatch and create a fresh
agent, but this relies on the new model's signature differing from the
old one in _agent_config_signature().

Evicting the cached agent explicitly after storing the session override
is more defensive — the next turn is guaranteed to create a fresh agent
from the override without depending on signature mismatch detection.

Also adds debug logging at three key decision points so we can trace
exactly what happens when /model + /retry interact:
- _resolve_session_agent_runtime: which override path is taken (fast
  with api_key vs fallback), or why no override was found
- _run_agent.run_sync: final resolved model/provider before agent
  creation

Reported: /model switch to xiaomi/mimo-v2-pro followed by /retry still
used the old model (glm-5.1).
2026-04-12 01:58:17 -07:00
Teknium 4aa534eae5 fix(gateway): peek at pending message during interrupt instead of consuming it
The monitor_for_interrupt() and backup interrupt checks were calling
get_pending_message() which pops the message from the adapter's queue.
This created a race condition: if the agent finished naturally before
checking _interrupt_requested, the pending message was permanently lost.

Timeline of the race:
1. Agent near completion, user sends message
2. Level 1 guard stores message in adapter._pending_messages, sets event
3. monitor_for_interrupt() detects event, POPS message, calls agent.interrupt()
4. Agent's run_conversation() was already returning (interrupted=False)
5. Post-run dequeue finds nothing (monitor already consumed it)
6. result.get('interrupted') is False so interrupt_message fallback doesn't fire
7. User message permanently lost — agent finishes without processing it

Fix: change all three interrupt detection sites (primary monitor + two
backup checks) from get_pending_message() (pop) to
_pending_messages.get() (peek). The message stays in the adapter's queue
until _dequeue_pending_event() consumes it in the post-run handler,
which runs regardless of whether the agent was interrupted or finished
naturally.

Reported by @_SushantSays — intermittent message loss during long
terminal command execution, persisting after the previous fix (73f970fa)
which addressed monitor task death but not this consumption race.
2026-04-12 01:57:34 -07:00
Teknium ae6820a45a fix(setup): validate base URL input in hermes model flow (#8264)
Reject non-URL values (e.g. shell commands typed by mistake) in the
base URL prompt during provider setup. Previously any string was saved
as-is to .env, breaking connectivity when the garbage value was used
as the API endpoint.

Adds http:// / https:// prefix check with a clear error message.
The custom-endpoint flow already had this validation (line 1620);
this brings the generic API-key provider flow to parity.

Triggered by a user support case where 'nano ~/.hermes/.env' was
accidentally entered as GLM_BASE_URL during Z.AI setup.
2026-04-12 01:51:57 -07:00
Teknium a1220977d3 fix: make skill loading instructions more aggressive in system prompt (#8209)
The previous wording ('If one clearly matches') set too high a threshold,
and 'If none match, proceed normally' was an easy escape hatch for lazy
models. Now:

- Lowered threshold: 'matches or is even partially relevant'
- Added MUST directive and 'err on the side of loading' guidance
- Replaced permissive closer with 'only proceed without if genuinely none
  are relevant'

This should reduce cases where the agent skips loading relevant skills
unless explicitly forced.
2026-04-12 01:46:34 -07:00
Teknium 078dba015d fix: three provider-related bugs (#8161, #8181, #8147) (#8243)
- Add openai/openai-codex -> openai mapping to PROVIDER_TO_MODELS_DEV
  so context-length lookups use models.dev data instead of 128k fallback.
  Fixes #8161.

- Set api_mode from custom_providers entry when switching via hermes model,
  and clear stale api_mode when the entry has none. Also extract api_mode
  in _named_custom_provider_map(). Fixes #8181.

- Convert OpenAI image_url content blocks to Anthropic image blocks when
  the endpoint is Anthropic-compatible (MiniMax, MiniMax-CN, or any URL
  containing /anthropic). Fixes #8147.
2026-04-12 01:44:18 -07:00
Harish Kukreja b1f13a8c5f fix(agent): route compression aux through live session runtime 2026-04-12 01:34:52 -07:00
Teknium c52f6348b6 fix: list all available toolsets in delegate_task schema description (#8231)
* fix: list all available toolsets in delegate_task schema description

The delegate_task tool's toolsets parameter description only mentioned
'terminal', 'file', and 'web' as examples. Models (especially smaller
ones like Gemma) would substitute 'web' for 'browser' because they
didn't know 'browser' was a valid option.

Now dynamically builds the toolset list from the TOOLSETS dict at import
time, excluding blocked, composite, and platform-specific toolsets.
Auto-updates when new toolsets are added.

Reported by jeffutter on Discord.

* chore: exclude moa and rl from delegate_task toolset list
2026-04-12 00:54:35 -07:00
Teknium 3162472674 feat(tips): add 69 deeper hidden-gem tips (279 total) (#8237)
Add lesser-known power-user tips covering:
- BOOT.md gateway startup automation
- Cron script attachment for data collection pipelines
- Prefill messages for few-shot priming
- Focus topic compression (/compress <topic>)
- Terminal exit code annotations and auto-retry
- Automatic sudo password piping
- execute_code built-in helpers (json_parse, shell_quote, retry)
- File loop detection and staleness warnings
- MCP sampling and dynamic tool discovery
- Delegation heartbeat and ACP child agents (Claude Code)
- 402 auto-fallback in auxiliary client
- Container mode, HERMES_HOME_MODE, subprocess HOME isolation
- Ctrl+C 5-tier priority system
- Browser CDP URL override and stealth mode
- Skills quarantine, audit log, and well-known protocol
- Per-platform display overrides, human delay mode
- And many more deep-cut features
2026-04-12 00:54:07 -07:00
Teknium 8b9d22a74b revert: keep debian:13.4 full image instead of slim
The slim image drops packages that may be needed at runtime.
Keep the full Debian base for compatibility.
2026-04-12 00:53:16 -07:00
m0n5t3r fee0e0d35e fix(docker): run as non-root user, use virtualenv (salvage #5811)
- Add gosu for runtime privilege dropping from root to hermes user
- Support HERMES_UID/HERMES_GID env vars for host mount permission matching
- Switch to debian:13.4-slim base image
- Use uv venv instead of pip install --break-system-packages
- Pin uv and gosu multi-stage images with SHA256 digests
- Set PLAYWRIGHT_BROWSERS_PATH to /opt/hermes/.playwright so build-time
  chromium install survives the /opt/data volume mount
- Keep procps for container debugging

Based on work by m0n5t3r in PR #5811. Stripped to hardening-only
changes (non-root, virtualenv, slim base); matrix deps, fonts, xvfb,
and entrypoint playwright download deferred to follow-up.
2026-04-12 00:53:16 -07:00
bravohenry 81ac62c0e9 fix(weixin): split chatty short replies into separate bubbles, keep structured content together
Add content-aware splitting to compact mode: short chat-like exchanges
(2-6 short lines without headings/lists/quotes) get separate message
bubbles for a natural chat feel, while structured content (tables,
headings with body, numbered lists) stays in a single message.

Cherry-picked from PR #7587 by bravohenry, adapted to the compact/legacy
split_per_line architecture from #7903.
2026-04-12 00:38:07 -07:00
Teknium f53a5a7fe1 fix: suppress duplicate completion notifications when agent already consumed output via wait/poll/log (#8228)
When the agent calls process(action='wait') or process(action='poll')
and gets the exited status, the completion_queue notification is
redundant — the agent already has the output from the tool return.
Previously, the drain loops in CLI and gateway would still inject
the [SYSTEM: Background process completed] message, causing the
agent to receive the same information twice.

Fix: track session IDs in _completion_consumed set when wait/poll/log
returns an exited process. Drain loops in cli.py and gateway watcher
skip completion events for consumed sessions. Watch pattern events
are never suppressed (they have independent semantics).

Adds 4 tests covering wait/poll/log marking and running-process
negative case.
2026-04-12 00:36:22 -07:00
Teknium fdf55e0fe9 feat(cli): show random tip on new session start (#8225)
Add a 'tip of the day' feature that displays a random one-liner about
Hermes Agent features on every new session — CLI startup, /clear, /new,
and gateway /new across all messaging platforms.

- New hermes_cli/tips.py module with 210 curated tips covering slash
  commands, keybindings, CLI flags, config options, tools, gateway
  platforms, profiles, sessions, memory, skills, cron, voice, security,
  and more
- CLI: tips display in skin-aware dim gold color after the welcome line
- Gateway: tips append to the /new and /reset response on all platforms
- Fully wrapped in try/except — tips are non-critical and never break
  startup or reset

Display format (CLI):
  ✦ Tip: /btw <question> asks a quick side question without tools or history.

Display format (gateway):
   Session reset! Starting fresh.
  ✦ Tip: hermes -c resumes your most recent CLI session.
2026-04-12 00:34:01 -07:00
opriz 36f57dbc51 fix(migration): don't auto-archive OpenClaw source directory
Remove auto-archival from hermes claw migrate — not its
responsibility (hermes claw cleanup is still there for that).

Skip MESSAGING_CWD when it points inside the OpenClaw source
directory, which was the actual root cause of agent confusion
after migration. Use Path.is_relative_to() for robust path
containment check.

Salvaged from PR #8192 by opriz.
Co-authored-by: opriz <opriz@users.noreply.github.com>
2026-04-12 00:33:54 -07:00
Teknium 1871227198 feat: rebrand OpenClaw references to Hermes during migration
- Add rebrand_text() that replaces OpenClaw, Open Claw, Open-Claw,
  ClawdBot, and MoltBot with Hermes (case-insensitive, word-boundary)
- Apply rebranding to memory entries (MEMORY.md, USER.md, daily memory)
- Apply rebranding to SOUL.md and workspace instructions via new
  transform parameter on copy_file()
- Fix moldbot -> moltbot typo across codebase (claw.py, migration
  script, docs, tests)
- Add unit tests for rebrand_text and integration tests for memory
  and soul migration rebranding
2026-04-12 00:33:54 -07:00
Teknium eb2a49f95a fix: openai-codex and anthropic not appearing in /model picker for external credentials (#8224)
Users whose credentials exist only in external files — OpenAI Codex
OAuth tokens in ~/.codex/auth.json or Anthropic Claude Code credentials
in ~/.claude/.credentials.json — would not see those providers in the
/model picker, even though hermes auth and hermes model detected them.

Root cause: list_authenticated_providers() only checked the raw Hermes
auth store and env vars. External credential file fallbacks (Codex CLI
import, Claude Code file discovery) were never triggered.

Fix (three parts):
1. _seed_from_singletons() in credential_pool.py: openai-codex now
   imports from ~/.codex/auth.json when the Hermes auth store is empty,
   mirroring resolve_codex_runtime_credentials().
2. list_authenticated_providers() in model_switch.py: auth store + pool
   checks now run for ALL providers (not just OAuth auth_type), catching
   providers like anthropic that support both API key and OAuth.
3. list_authenticated_providers(): direct check for anthropic external
   credential files (Claude Code, Hermes PKCE). The credential pool
   intentionally gates anthropic behind is_provider_explicitly_configured()
   to prevent auxiliary tasks from silently consuming tokens. The /model
   picker bypasses this gate since it is discovery-oriented.
2026-04-12 00:33:42 -07:00
Teknium 73f970fa4d fix: make gateway interrupt detection resilient to monitor task failures
The interrupt mechanism for regular text messages (non-commands) during
active agent runs relied on a single async polling task
(monitor_for_interrupt) with no error handling. If this task died
silently due to an unhandled exception, stale adapter reference after
reconnect, or any other failure, user messages sent during agent
execution would be queued but never trigger an actual interrupt — the
agent would continue running until it finished naturally, then process
the queued message.

Three improvements:

1. Error handling in monitor_for_interrupt(): wrap the polling body in
   try/except so transient errors are logged and retried instead of
   silently killing the task.

2. Fresh adapter reference on each poll iteration: re-resolve
   self.adapters.get(source.platform) every 200ms instead of capturing
   the adapter once at task creation time. This prevents stale
   references after adapter reconnects.

3. Backup interrupt check in the inactivity poll loop: both the
   unlimited and timeout-enabled paths now check for pending interrupts
   every 5 seconds (the existing poll interval). Uses a shared
   _interrupt_detected asyncio.Event to avoid double-firing when the
   primary monitor already handled the interrupt. Logs at INFO level
   with monitor task state for debugging.
2026-04-12 00:25:05 -07:00
Teknium 4cadfef8e3 fix(cli): restore stacked tool progress scrollback in TUI (#8201)
The TUI transition (4970705, f83e86d) replaced stacked per-tool history
lines with a single live-updating spinner widget. While the spinner
provides a nice live timer, it removed the scrollback history that
users relied on to see what the agent did during a session.

This restores stacked tool progress lines in 'all' and 'new' modes by
printing persistent scrollback lines via _cprint() when tools complete,
in addition to the existing live spinner display.

Behavior per mode:
- off: no scrollback lines, no spinner (unchanged)
- new: scrollback line on completion, skipping consecutive same-tool repeats
- all: scrollback line on every tool completion
- verbose: no scrollback (run_agent.py handles verbose output directly)

Implementation:
- Store function_args from tool.started events in _pending_tool_info
- On tool.completed, pop stored args and format via get_cute_tool_message()
- FIFO queue per function_name handles concurrent tool execution
- 'new' mode tracks _last_scrollback_tool for dedup
- State cleared at end of agent run

Reported by community user Mr.D — the stacked history provides
transparency into what the agent is doing, which builds trust.

Addresses user report from Discord about lost tool call visibility.
2026-04-11 23:22:34 -07:00
Teknium 8e00b3a69e fix(cron): steer model away from explicit deliver targets that lose topic context (#8187)
Rewrite the cronjob tool's 'deliver' parameter description to strongly
guide models toward omitting the parameter (which auto-detects origin
including thread/topic). The previous description listed all platform
names equally, inviting models to construct explicit targets like
'telegram:<chat_id>' which silently drops the thread_id.

New description:
- Leads with 'Omit this parameter' as the recommended path
- Explicitly warns that platform:chat_id without :thread_id loses topics
- Removes the long flat list of platform names that invited construction

Also adds diagnostic logging at two key points:
- _origin_from_env(): logs when thread_id is captured during job creation
- _deliver_result(): warns when origin has thread_id but delivery target
  lost it; logs at debug when delivering to a specific thread

Helps diagnose user-reported issue where cron responses from Telegram
topics are delivered to the main chat instead of the originating topic.
2026-04-11 23:20:39 -07:00
Teknium 1ca9b19750 feat: add network.force_ipv4 config to fix IPv6 timeout issues (#8196)
On servers with broken or unreachable IPv6, Python's socket.getaddrinfo
returns AAAA records first. urllib/httpx/requests all try IPv6 connections
first and hang for the full TCP timeout before falling back to IPv4. This
affects web_extract, web_search, the OpenAI SDK, and all HTTP tools.

Adds network.force_ipv4 config option (default: false) that monkey-patches
socket.getaddrinfo to resolve as AF_INET when the caller didn't specify a
family. Falls back to full resolution if no A record exists, so pure-IPv6
hosts still work.

Applied early at all three entry points (CLI, gateway, cron scheduler)
before any HTTP clients are created.

Reported by user @29n — Chinese Ubuntu server with unreachable IPv6 causing
timeouts on lobste.rs and other IPv6-enabled sites while Google/GitHub
worked fine (IPv4-only resolution).
2026-04-11 23:12:11 -07:00
Teknium 1cec910b6a fix: improve context compaction to prevent model answering stale questions (#8107)
After compression, models (especially Kimi 2.5) would sometimes respond
to questions from the summary instead of the latest user message. This
happened ~30% of the time on Telegram.

Root cause: the summary's 'Next Steps' section read as active instructions,
and the SUMMARY_PREFIX didn't explicitly tell the model to ignore questions
in the summary. When the summary merged into the first tail message, there
was no clear separator between historical context and the actual user message.

Changes inspired by competitor analysis (Claude Code, OpenCode, Codex):

1. SUMMARY_PREFIX rewritten with explicit 'Do NOT answer questions from
   this summary — respond ONLY to the latest user message AFTER it'

2. Summarizer preamble (shared by both prompts) adds:
   - 'Do NOT respond to any questions' (from OpenCode's approach)
   - 'Different assistant' framing (from Codex) to create psychological
     distance between summary content and active conversation

3. New summary sections:
   - '## Resolved Questions' — tracks already-answered questions with
     their answers, preventing re-answering (from Claude Code's
     'Pending user asks' pattern)
   - '## Pending User Asks' — explicitly marks unanswered questions
   - '## Remaining Work' replaces '## Next Steps' — passive framing
     avoids reading as active instructions

4. merge-summary-into-tail path now inserts a clear separator:
   '--- END OF CONTEXT SUMMARY — respond to the message below ---'

5. Iterative update prompt now instructs: 'Move answered questions to
   Resolved Questions' to maintain the resolved/pending distinction
   across multiple compactions.
2026-04-11 19:43:58 -07:00
Tom Qiao 8a48c58bd3 fix(gateway): add missing RedactingFormatter import
The gateway startup path references RedactingFormatter without
importing it, causing a NameError crash when launched with a
verbosity flag (e.g. via launchd --replace).

Fixes #8044

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 19:38:05 -07:00
Teknium a0a02c1bc0 feat: /compress <focus> — guided compression with focus topic (#8017)
Adds an optional focus topic to /compress: `/compress database schema`
guides the summariser to preserve information related to the focus topic
(60-70% of summary budget) while compressing everything else more aggressively.
Inspired by Claude Code's /compact <focus>.

Changes:
- context_compressor.py: focus_topic parameter on _generate_summary() and
  compress(); appends FOCUS TOPIC guidance block to the LLM prompt
- run_agent.py: focus_topic parameter on _compress_context(), passed through
  to the compressor
- cli.py: _manual_compress() extracts focus topic from command string,
  preserves existing manual_compression_feedback integration (no regression)
- gateway/run.py: _handle_compress_command() extracts focus from event args
  and passes through — full gateway parity
- commands.py: args_hint="[focus topic]" on /compress CommandDef

Salvaged from PR #7459 (CLI /compress focus only — /context command deferred).
15 new tests across CLI, compressor, and gateway.
2026-04-11 19:23:29 -07:00
helix4u cfbfc4c3f1 fix(discord): decouple readiness from slash sync 2026-04-11 19:22:14 -07:00
Teknium fa7cd44b92 feat: add hermes backup and hermes import commands (#7997)
* feat: add `hermes backup` and `hermes import` commands

hermes backup — creates a zip of ~/.hermes/ (config, skills, sessions,
profiles, memories, skins, cron jobs, etc.) excluding the hermes-agent
codebase, __pycache__, and runtime PID files. Defaults to
~/hermes-backup-<timestamp>.zip, customizable with -o.

hermes import <zipfile> — restores from a backup zip, validating it
looks like a hermes backup before extracting. Handles .hermes/ prefix
stripping, path traversal protection, and confirmation prompts (skip
with --force).

29 tests covering exclusion rules, backup creation, import validation,
prefix detection, path traversal blocking, confirmation flow, and a
full round-trip test.

* test: improve backup/import coverage to 97%

Add 17 additional tests covering:
- _format_size helper (bytes through terabytes)
- Nonexistent hermes home error exit
- Output path is a directory (auto-names inside it)
- Output without .zip suffix (auto-appends)
- Empty hermes home (all files excluded)
- Permission errors during backup and import
- Output zip inside hermes root (skips itself)
- Not-a-zip file rejection
- EOFError and KeyboardInterrupt during confirmation
- 500+ file progress display
- Directory-only zip prefix detection

Remove dead code branch in _detect_prefix (unreachable guard).

* feat: auto-restore profile wrapper scripts on import

After extracting backup files, hermes import now scans profiles/ for
subdirectories with config.yaml or .env and recreates the ~/.local/bin
wrapper scripts so profile aliases (e.g. 'coder chat') work immediately.

Also prints guidance for re-installing gateway services per profile.

Handles edge cases:
- Skips profile dirs without config (not real profiles)
- Skips aliases that collide with existing commands
- Gracefully degrades if hermes_cli.profiles isn't available (fresh install)
- Shows PATH hint if ~/.local/bin isn't in PATH

3 new profile restoration tests (49 total).
2026-04-11 19:15:50 -07:00
Siddharth Balyan 50d86b3c71 fix(matrix): replace pickle crypto store with SQLite, fix E2EE decryption (#7981)
Fixes #7952 — Matrix E2EE completely broken after mautrix migration.

- Replace MemoryCryptoStore + pickle/HMAC persistence with mautrix's
  PgCryptoStore backed by SQLite via aiosqlite. Crypto state now
  persists reliably across restarts without fragile serialization.

- Add handle_sync() call on initial sync response so to-device events
  (queued Megolm key shares) are dispatched to OlmMachine instead of
  being silently dropped.

- Add _verify_device_keys_on_server() after loading crypto state.
  Detects missing keys (re-uploads), stale keys from migration
  (attempts re-upload), and corrupted state (refuses E2EE).

- Add _CryptoStateStore adapter wrapping MemoryStateStore to satisfy
  mautrix crypto's StateStore interface (is_encrypted,
  get_encryption_info, find_shared_rooms).

- Remove redundant share_keys() call from sync loop — OlmMachine
  already handles this via DEVICE_OTK_COUNT event handler.

- Fix datetime vs float TypeError in session.py suspend_recently_active()
  that crashed gateway startup.

- Add aiosqlite and asyncpg to [matrix] extra in pyproject.toml.

- Update test mocks for PgCryptoStore/Database and add query_keys mock
  for key verification. 174 tests pass.

- Add E2EE upgrade/migration docs to Matrix user guide.
2026-04-12 07:24:46 +05:30
85 changed files with 6493 additions and 1590 deletions
+2 -7
View File
@@ -69,9 +69,7 @@ jobs:
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.sha }}
tags: nousresearch/hermes-agent:latest
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -83,9 +81,6 @@ jobs:
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.event.release.tag_name }}
nousresearch/hermes-agent:${{ github.sha }}
tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
+23 -6
View File
@@ -1,27 +1,44 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# Install system dependencies in one layer, clear APT cache
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
COPY . /opt/hermes
WORKDIR /opt/hermes
# Install Python and Node dependencies in one layer, no cache
RUN pip install --no-cache-dir uv --break-system-packages && \
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
npm install --prefer-offline --no-audit && \
# Install Node dependencies and Playwright as root (--with-deps needs apt)
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
cd /opt/hermes/scripts/whatsapp-bridge && \
npm install --prefer-offline --no-audit && \
npm cache clean --force
WORKDIR /opt/hermes
# Hand ownership to hermes user, then install Python deps in a virtualenv
RUN chown -R hermes:hermes /opt/hermes
USER hermes
RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"
USER root
RUN chmod +x /opt/hermes/docker/entrypoint.sh
ENV HERMES_HOME=/opt/data
+4
View File
@@ -0,0 +1,4 @@
FROM nousresearch/hermes-agent:latest
COPY hermes_cli/ /opt/hermes/hermes_cli/
COPY hermes_constants.py /opt/hermes/hermes_constants.py
COPY tools/voice_mode.py /opt/hermes/tools/voice_mode.py
+138 -10
View File
@@ -1021,6 +1021,23 @@ _AUTO_PROVIDER_LABELS = {
_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode")
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""Return a sanitized copy of a live main-runtime override."""
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, str] = {}
for field in _MAIN_RUNTIME_FIELDS:
value = main_runtime.get(field)
if isinstance(value, str) and value.strip():
normalized[field] = value.strip()
provider = normalized.get("provider")
if provider:
normalized["provider"] = provider.lower()
return normalized
def _get_provider_chain() -> List[tuple]:
"""Return the ordered provider detection chain.
@@ -1130,7 +1147,7 @@ def _try_payment_fallback(
return None, None, ""
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.
Priority:
@@ -1142,6 +1159,12 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
"""
global auxiliary_is_nous, _stale_base_url_warned
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
runtime = _normalize_main_runtime(main_runtime)
runtime_provider = runtime.get("provider", "")
runtime_model = runtime.get("model", "")
runtime_base_url = runtime.get("base_url", "")
runtime_api_key = runtime.get("api_key", "")
runtime_api_mode = runtime.get("api_mode", "")
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
@@ -1149,7 +1172,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
# old OPENAI_BASE_URL lingers in ~/.hermes/.env. ──
if not _stale_base_url_warned:
_env_base = os.getenv("OPENAI_BASE_URL", "").strip()
_cfg_provider = _read_main_provider()
_cfg_provider = runtime_provider or _read_main_provider()
if (_env_base and _cfg_provider
and _cfg_provider != "custom"
and not _cfg_provider.startswith("custom:")):
@@ -1163,12 +1186,25 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
_stale_base_url_warned = True
# ── Step 1: non-aggregator main provider → use main model directly ──
main_provider = _read_main_provider()
main_model = _read_main_model()
main_provider = runtime_provider or _read_main_provider()
main_model = runtime_model or _read_main_model()
if (main_provider and main_model
and main_provider not in _AGGREGATOR_PROVIDERS
and main_provider not in ("auto", "")):
client, resolved = resolve_provider_client(main_provider, main_model)
resolved_provider = main_provider
explicit_base_url = None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
explicit_base_url = runtime_base_url
explicit_api_key = runtime_api_key or None
client, resolved = resolve_provider_client(
resolved_provider,
main_model,
explicit_base_url=explicit_base_url,
explicit_api_key=explicit_api_key,
api_mode=runtime_api_mode or None,
)
if client is not None:
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
main_provider, resolved or main_model)
@@ -1249,6 +1285,7 @@ def resolve_provider_client(
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -1319,7 +1356,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto()
client, resolved = _resolve_auto(main_runtime=main_runtime)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
@@ -1543,7 +1580,11 @@ def resolve_provider_client(
# ── Public API ──────────────────────────────────────────────────────────────
def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optional[str]]:
def get_text_auxiliary_client(
task: str = "",
*,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Return (client, default_model_slug) for text-only auxiliary tasks.
Args:
@@ -1560,10 +1601,11 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=main_runtime,
)
def get_async_text_auxiliary_client(task: str = ""):
def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Dict[str, Any]] = None):
"""Return (async_client, model_slug) for async consumers.
For standard providers returns (AsyncOpenAI, model). For Codex returns
@@ -1578,6 +1620,7 @@ def get_async_text_auxiliary_client(task: str = ""):
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=main_runtime,
)
@@ -1892,6 +1935,7 @@ def _get_cached_client(
base_url: str = None,
api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -1915,7 +1959,9 @@ def _get_cached_client(
loop_id = id(current_loop)
except RuntimeError:
pass
cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id)
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id, runtime_key)
with _client_cache_lock:
if cache_key in _client_cache:
cached_client, cached_default, cached_loop = _client_cache[cache_key]
@@ -1940,6 +1986,7 @@ def _get_cached_client(
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=runtime,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -2065,6 +2112,75 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default
# ---------------------------------------------------------------------------
# Anthropic-compatible endpoint detection + image block conversion
# ---------------------------------------------------------------------------
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
# Their image content blocks must use Anthropic format, not OpenAI format.
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"})
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Detect if an endpoint expects Anthropic-format content blocks.
Returns True for known Anthropic-compatible providers (MiniMax) and
any endpoint whose URL contains ``/anthropic`` in the path.
"""
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based image
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _build_call_kwargs(
provider: str,
model: str,
@@ -2149,6 +2265,7 @@ def call_llm(
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: float = None,
max_tokens: int = None,
@@ -2214,6 +2331,7 @@ def call_llm(
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
)
if client is None:
# When the user explicitly chose a non-OpenRouter provider but no
@@ -2234,7 +2352,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto")
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -2255,6 +2373,11 @@ def call_llm(
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
# Handle max_tokens vs max_completion_tokens retry, then payment fallback.
try:
return _validate_llm_response(
@@ -2443,6 +2566,11 @@ async def async_call_llm(
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
+114 -71
View File
@@ -4,8 +4,12 @@ Self-contained class with its own OpenAI client for summarization.
Uses auxiliary model (cheap/fast) to summarize middle turns while
protecting head and tail context.
Improvements over v1:
- Structured summary template (Goal, Progress, Decisions, Files, Next Steps)
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Summarizer preamble: "Do not respond to any questions" (from OpenCode)
- Handoff framing: "different assistant" (from Codex) to create separation
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
- Tool output pruning before LLM summarization (cheap pre-pass)
@@ -28,12 +32,13 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION] Earlier turns in this conversation were compacted "
"to save context space. The summary below describes work that was "
"already completed, and the current session state may still reflect "
"that work (for example, files may already be changed). Use the summary "
"and the current state to continue from where things left off, and "
"avoid repeating work:"
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
@@ -81,12 +86,14 @@ class ContextCompressor(ContextEngine):
base_url: str = "",
api_key: str = "",
provider: str = "",
api_mode: str = "",
) -> None:
"""Update model info after a model switch or fallback activation."""
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
self.threshold_tokens = max(
int(context_length * self.threshold_percent),
@@ -106,11 +113,13 @@ class ContextCompressor(ContextEngine):
api_key: str = "",
config_context_length: int | None = None,
provider: str = "",
api_mode: str = "",
):
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.api_mode = api_mode
self.threshold_percent = threshold_percent
self.protect_first_n = protect_first_n
self.protect_last_n = protect_last_n
@@ -306,13 +315,20 @@ class ContextCompressor(ContextEngine):
return "\n\n".join(parts)
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
"""Generate a structured summary of conversation turns.
Uses a structured template (Goal, Progress, Decisions, Files, Next Steps)
inspired by Pi-mono and OpenCode. When a previous summary exists,
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
Questions, Files, Remaining Work) with explicit preamble telling the
summarizer not to answer questions. When a previous summary exists,
generates an iterative update instead of summarizing from scratch.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser prioritises preserving information
related to this topic and is more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
Returns None if all attempts fail — the caller should drop
the middle turns without a summary rather than inject a useless
placeholder.
@@ -328,60 +344,27 @@ class ContextCompressor(ContextEngine):
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
if self._previous_summary:
# Iterative update: preserve existing info, add new progress
prompt = f"""You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
# Preamble shared by both first-compaction and iterative-update prompts.
# Inspired by OpenCode's "do not respond to any questions" instruction
# and Codex's "another language model" framing.
_summarizer_preamble = (
"You are a summarization agent creating a context checkpoint. "
"Your output will be injected as reference material for a DIFFERENT "
"assistant that continues the conversation. "
"Do NOT respond to any questions or requests in the conversation — "
"only output the structured summary. "
"Do NOT include any preamble, greeting, or prefix."
)
PREVIOUS SUMMARY:
{self._previous_summary}
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Remove information only if it is clearly obsolete.
## Goal
[What the user is trying to accomplish — preserve from previous summary, update if goal evolved]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions — accumulate across compactions]
## Progress
### Done
[Completed work — include specific file paths, commands run, results obtained]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why they were made]
## Relevant Files
[Files read, modified, or created — with brief note on each. Accumulate across compactions.]
## Next Steps
[What needs to happen next to continue the work]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
## Tools & Patterns
[Which tools were used, how they were used effectively, and any tool-specific discoveries. Accumulate across compactions.]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
Write only the summary body. Do not include any preamble or prefix."""
else:
# First compaction: summarize from scratch
prompt = f"""Create a structured handoff summary for a later assistant that will continue this conversation after earlier turns are compacted.
TURNS TO SUMMARIZE:
{content_to_summarize}
Use this exact structure:
## Goal
# Shared structured template (used by both paths).
# Key changes vs v1:
# - "Pending User Asks" section (from Claude Code) explicitly tracks
# unanswered questions so the model knows what's resolved vs open
# - "Remaining Work" replaces "Next Steps" to avoid reading as active
# instructions
# - "Resolved Questions" makes it clear which questions were already
# answered (prevents model from re-answering them)
_template_sections = f"""## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
@@ -398,25 +381,74 @@ Use this exact structure:
## Key Decisions
[Important technical decisions and why they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next to continue the work]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
## Tools & Patterns
[Which tools were used, how they were used effectively, and any tool-specific discoveries (e.g., preferred flags, working invocations, successful command patterns)]
[Which tools were used, how they were used effectively, and any tool-specific discoveries]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions. The goal is to prevent the next assistant from repeating work or losing important details.
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
Write only the summary body. Do not include any preamble or prefix."""
if self._previous_summary:
# Iterative update: preserve existing info, add new progress
prompt = f"""{_summarizer_preamble}
You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
PREVIOUS SUMMARY:
{self._previous_summary}
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Move answered questions to "Resolved Questions". Remove information only if it is clearly obsolete.
{_template_sections}"""
else:
# First compaction: summarize from scratch
prompt = f"""{_summarizer_preamble}
Create a structured handoff summary for a different assistant that will continue this conversation after earlier turns are compacted. The next assistant should be able to understand what happened without re-reading the original turns.
TURNS TO SUMMARIZE:
{content_to_summarize}
Use this exact structure:
{_template_sections}"""
# Inject focus topic guidance when the user provides one via /compress <focus>.
# This goes at the end of the prompt so it takes precedence.
if focus_topic:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget."""
try:
call_kwargs = {
"task": "compression",
"main_runtime": {
"model": self.model,
"provider": self.provider,
"base_url": self.base_url,
"api_key": self.api_key,
"api_mode": self.api_mode,
},
"messages": [{"role": "user", "content": prompt}],
"max_tokens": summary_budget * 2,
# timeout resolved from auxiliary.compression.timeout config by call_llm
@@ -631,7 +663,7 @@ Write only the summary body. Do not include any preamble or prefix."""
# Main compression entry point
# ------------------------------------------------------------------
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]:
"""Compress conversation messages by summarizing middle turns.
Algorithm:
@@ -643,6 +675,12 @@ Write only the summary body. Do not include any preamble or prefix."""
After compression, orphaned tool_call / tool_result pairs are cleaned
up so the API never receives mismatched IDs.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser will prioritise preserving information
related to this topic and be more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
"""
n_messages = len(messages)
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
@@ -700,7 +738,7 @@ Write only the summary body. Do not include any preamble or prefix."""
)
# Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize)
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
# Phase 4: Assemble compressed message list
compressed = []
@@ -755,7 +793,12 @@ Write only the summary body. Do not include any preamble or prefix."""
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
original = msg.get("content") or ""
msg["content"] = summary + "\n\n" + original
msg["content"] = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---\n\n"
+ original
)
_merge_summary_into_tail = False
compressed.append(msg)
+37
View File
@@ -24,6 +24,7 @@ from hermes_cli.auth import (
_codex_access_token_is_expiring,
_decode_jwt_claims,
_import_codex_cli_tokens,
_write_codex_cli_tokens,
_load_auth_store,
_load_provider_state,
_resolve_kimi_base_url,
@@ -693,6 +694,14 @@ class CredentialPool:
self._replace_entry(synced, updated)
self._persist()
self._sync_device_code_entry_to_auth_store(updated)
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Codex retry refresh also failed: %s", retry_exc)
@@ -718,6 +727,17 @@ class CredentialPool:
# _seed_from_singletons() on the next load_pool() sees fresh state
# instead of re-seeding stale/consumed tokens.
self._sync_device_code_entry_to_auth_store(updated)
# Write refreshed tokens back to ~/.codex/auth.json so Codex CLI
# and VS Code don't hit "refresh_token_reused" on their next refresh.
if self.provider == "openai-codex":
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc)
return updated
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
@@ -1128,6 +1148,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
elif provider == "openai-codex":
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
# Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
# store has no tokens. This mirrors resolve_codex_runtime_credentials()
# so that load_pool() and list_authenticated_providers() detect tokens
# that only exist in the Codex CLI shared file.
if not (isinstance(tokens, dict) and tokens.get("access_token")):
try:
from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Importing Codex CLI tokens into Hermes auth store.")
_save_codex_tokens(cli_tokens)
# Re-read state after import
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
except Exception as exc:
logger.debug("Codex CLI token import failed: %s", exc)
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("device_code")
changed |= _upsert_entry(
+2
View File
@@ -144,6 +144,8 @@ class ProviderInfo:
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openrouter": "openrouter",
"anthropic": "anthropic",
"openai": "openai",
"openai-codex": "openai",
"zai": "zai",
"kimi-coding": "kimi-for-coding",
"minimax": "minimax",
+42 -4
View File
@@ -12,7 +12,7 @@ import threading
from collections import OrderedDict
from pathlib import Path
from hermes_constants import get_hermes_home, get_skills_dir
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
from typing import Optional
from agent.skill_utils import (
@@ -366,6 +366,36 @@ PLATFORM_HINTS = {
),
}
# ---------------------------------------------------------------------------
# Environment hints — execution-environment awareness for the agent.
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
# the machine/OS the agent's tools actually run on.
# ---------------------------------------------------------------------------
WSL_ENVIRONMENT_HINT = (
"You are running inside WSL (Windows Subsystem for Linux). "
"The Windows host filesystem is mounted under /mnt/ — "
"/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. "
"The user's Windows files are typically at "
"/mnt/c/Users/<username>/Desktop/, Documents/, Downloads/, etc. "
"When the user references Windows paths or desktop files, translate "
"to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover "
"the Windows username if needed."
)
def build_environment_hints() -> str:
"""Return environment-specific guidance for the system prompt.
Detects WSL, and can be extended for Termux, Docker, etc.
Returns an empty string when no special environment is detected.
"""
hints: list[str] = []
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
return "\n\n".join(hints)
CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
@@ -726,8 +756,16 @@ def build_skills_system_prompt(
result = (
"## Skills (mandatory)\n"
"Before replying, scan the skills below. If one clearly matches your task, "
"load it with skill_view(name) and follow its instructions. "
"Before replying, scan the skills below. If a skill matches or is even partially relevant "
"to your task, you MUST load it with skill_view(name) and follow its instructions. "
"Err on the side of loading — it is always better to have context you don't need "
"than to miss critical steps, pitfalls, or established workflows. "
"Skills contain specialized knowledge — API endpoints, tool-specific commands, "
"and proven workflows that outperform general-purpose approaches. Load the skill "
"even if you think you could handle the task with basic tools like web_search or terminal. "
"Skills also encode the user's preferred approach, conventions, and quality standards "
"for tasks like code review, planning, and testing — load them even for tasks you "
"already know how to do, because the skill defines how it should be done here.\n"
"If a skill has issues, fix it with skill_manage(action='patch').\n"
"After difficult/iterative tasks, offer to save as a skill. "
"If a skill you loaded was missing steps, had wrong commands, or needed "
@@ -737,7 +775,7 @@ def build_skills_system_prompt(
+ "\n".join(index_lines) + "\n"
"</available_skills>\n"
"\n"
"If none match, proceed normally without loading a skill."
"Only proceed without loading a skill if genuinely none are relevant to the task."
)
# ── Store in LRU cache ────────────────────────────────────────────
+1 -1
View File
@@ -36,7 +36,7 @@ def generate_title(user_message: str, assistant_response: str, timeout: float =
try:
response = call_llm(
task="compression", # reuse compression task config (cheap/fast model)
task="title_generation",
messages=messages,
max_tokens=30,
temperature=0.3,
+119 -7
View File
@@ -1822,6 +1822,8 @@ class HermesCLI:
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed)
self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
self._command_running = False
self._command_status = ""
self._attached_images: list[Path] = []
@@ -2733,6 +2735,22 @@ class HermesCLI:
if runtime_model and isinstance(runtime_model, str):
self.model = runtime_model
# If model is still empty (e.g. user ran `hermes auth add openai-codex`
# without `hermes model`), fall back to the provider's first catalog
# model so the API call doesn't fail with "model must be non-empty".
if not self.model and resolved_provider:
try:
from hermes_cli.models import get_default_model_for_provider
_default = get_default_model_for_provider(resolved_provider)
if _default:
self.model = _default
logger.info(
"No model configured — defaulting to %s for provider %s",
_default, resolved_provider,
)
except Exception:
pass
# Normalize model for the resolved provider (e.g. swap non-Codex
# models when provider is openai-codex). Fixes #651.
model_changed = self._normalize_model_for_provider(resolved_provider)
@@ -5242,9 +5260,33 @@ class HermesCLI:
context_length=ctx_len,
)
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
from hermes_cli.skin_engine import get_active_skin
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass
else:
self.show_banner()
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
from hermes_cli.skin_engine import get_active_skin
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass
elif canonical == "history":
self.show_history()
elif canonical == "title":
@@ -5344,7 +5386,7 @@ class HermesCLI:
elif canonical == "fast":
self._handle_fast_command(cmd_original)
elif canonical == "compress":
self._manual_compress()
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "insights":
@@ -6201,8 +6243,14 @@ class HermesCLI:
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
self._flush_reasoning_preview(force=False)
def _manual_compress(self):
"""Manually trigger context compression on the current conversation."""
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.
"""
if not self.conversation_history or len(self.conversation_history) < 4:
print("(._.) Not enough conversation to compress (need at least 4 messages).")
return
@@ -6215,18 +6263,30 @@ class HermesCLI:
print("(._.) Compression is disabled in config.")
return
# Extract optional focus topic from the command (e.g. "/compress database schema")
focus_topic = ""
if cmd_original:
parts = cmd_original.strip().split(None, 1)
if len(parts) > 1:
focus_topic = parts[1].strip()
original_count = len(self.conversation_history)
try:
from agent.model_metadata import estimate_messages_tokens_rough
from agent.manual_compression_feedback import summarize_manual_compression
original_history = list(self.conversation_history)
approx_tokens = estimate_messages_tokens_rough(original_history)
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
if focus_topic:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
f"focus: \"{focus_topic}\"...")
else:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
compressed, _ = self.agent._compress_context(
original_history,
self.agent._cached_system_prompt or "",
approx_tokens=approx_tokens,
focus_topic=focus_topic or None,
)
self.conversation_history = compressed
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
@@ -6542,10 +6602,36 @@ class HermesCLI:
On tool.started, records a monotonic timestamp so get_spinner_text()
can show a live elapsed timer (the TUI poll loop already invalidates
every ~0.15s, so the counter updates automatically).
When tool_progress_mode is "all" or "new", also prints a persistent
stacked line to scrollback on tool.completed so users can see the
full history of tool calls (not just the current one in the spinner).
"""
if event_type == "tool.completed":
import time as _time
self._tool_start_time = 0.0
# Print stacked scrollback line for "all" / "new" modes
if function_name and self.tool_progress_mode in ("all", "new"):
duration = kwargs.get("duration", 0.0)
is_error = kwargs.get("is_error", False)
# Pop stored args from tool.started for this function
stored = self._pending_tool_info.get(function_name)
stored_args = stored.pop(0) if stored else {}
if stored is not None and not stored:
del self._pending_tool_info[function_name]
# "new" mode: skip consecutive repeats of the same tool
if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool:
self._invalidate()
return
self._last_scrollback_tool = function_name
try:
from agent.display import get_cute_tool_message
line = get_cute_tool_message(function_name, stored_args, duration)
if is_error:
line = f"{line} [error]"
_cprint(f" {line}")
except Exception:
pass
self._invalidate()
return
if event_type != "tool.started":
@@ -6561,6 +6647,10 @@ class HermesCLI:
label = label[:_pl - 3] + "..."
self._spinner_text = f"{emoji} {label}"
self._tool_start_time = _time.monotonic()
# Store args for stacked scrollback line on completion
self._pending_tool_info.setdefault(function_name, []).append(
function_args if function_args is not None else {}
)
self._invalidate()
if not self._voice_mode:
@@ -8025,6 +8115,17 @@ class HermesCLI:
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC"
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
# Show a random tip to help users discover features
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass # Tips are non-critical — never break startup
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self.console.print(
@@ -9300,9 +9401,14 @@ class HermesCLI:
from tools.process_registry import process_registry
if not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
# Skip if the agent already consumed this via wait/poll/log
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
pass # already delivered via tool result
else:
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
except Exception:
pass
continue
@@ -9401,6 +9507,8 @@ class HermesCLI:
self._agent_running = False
self._spinner_text = ""
self._tool_start_time = 0.0
self._pending_tool_info.clear()
self._last_scrollback_tool = ""
app.invalidate() # Refresh status line
@@ -9426,6 +9534,10 @@ class HermesCLI:
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
# Skip if the agent already consumed this via wait/poll/log
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
continue # already delivered via tool result
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
+24
View File
@@ -219,6 +219,21 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
chat_id = target["chat_id"]
thread_id = target.get("thread_id")
# Diagnostic: log thread_id for topic-aware delivery debugging
origin = job.get("origin") or {}
origin_thread = origin.get("thread_id")
if origin_thread and not thread_id:
logger.warning(
"Job '%s': origin has thread_id=%s but delivery target lost it "
"(deliver=%s, target=%s)",
job["id"], origin_thread, job.get("deliver", "local"), target,
)
elif thread_id:
logger.debug(
"Job '%s': delivering to %s:%s thread_id=%s",
job["id"], platform_name, chat_id, thread_id,
)
from tools.send_message_tool import _send_to_platform
from gateway.config import load_gateway_config, Platform
@@ -626,6 +641,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except Exception as e:
logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e)
# Apply IPv4 preference if configured.
try:
from hermes_constants import apply_ipv4_preference
_net_cfg = _cfg.get("network", {})
if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception:
pass
# Reasoning config from config.yaml
from hermes_constants import parse_reasoning_effort
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
+27
View File
@@ -5,6 +5,33 @@ set -e
HERMES_HOME="/opt/data"
INSTALL_DIR="/opt/hermes"
# --- Privilege dropping via gosu ---
# When started as root (the default), optionally remap the hermes user/group
# to match host-side ownership, fix volume permissions, then re-exec as hermes.
if [ "$(id -u)" = "0" ]; then
if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
echo "Changing hermes UID to $HERMES_UID"
usermod -u "$HERMES_UID" hermes
fi
if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
echo "Changing hermes GID to $HERMES_GID"
groupmod -g "$HERMES_GID" hermes
fi
actual_hermes_uid=$(id -u hermes)
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing"
chown -R hermes:hermes "$HERMES_HOME"
fi
echo "Dropping root privileges"
exec gosu hermes "$0" "$@"
fi
# --- Running as hermes from here ---
source "${INSTALL_DIR}/.venv/bin/activate"
# Create essential directory structure. Cache and platform directories
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
# demand by the application — don't pre-create them here so new installs
+1 -1
View File
@@ -118,7 +118,7 @@ For executed migrations, the full report is saved to `~/.hermes/migration/opencl
## Troubleshooting
### "OpenClaw directory not found"
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`:
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`:
```bash
hermes claw migrate --source /path/to/.openclaw
```
+29 -7
View File
@@ -456,6 +456,7 @@ class DiscordAdapter(BasePlatformAdapter):
# show the standard typing gateway event for bots)
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._bot_task: Optional[asyncio.Task] = None
self._post_connect_task: Optional[asyncio.Task] = None
# Dedup cache: prevents duplicate bot responses when Discord
# RESUME replays events after reconnects.
self._dedup = MessageDeduplicator()
@@ -545,15 +546,14 @@ class DiscordAdapter(BasePlatformAdapter):
# Resolve any usernames in the allowed list to numeric IDs
await adapter_self._resolve_allowed_usernames()
# Sync slash commands with Discord
try:
synced = await adapter_self._client.tree.sync()
logger.info("[%s] Synced %d slash command(s)", adapter_self.name, len(synced))
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[%s] Slash command sync failed: %s", adapter_self.name, e, exc_info=True)
adapter_self._ready_event.set()
if adapter_self._post_connect_task and not adapter_self._post_connect_task.done():
adapter_self._post_connect_task.cancel()
adapter_self._post_connect_task = asyncio.create_task(
adapter_self._run_post_connect_initialization()
)
@self._client.event
async def on_message(message: DiscordMessage):
# Dedup: Discord RESUME replays events after reconnects (#4777)
@@ -686,14 +686,36 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[%s] Error during disconnect: %s", self.name, e, exc_info=True)
if self._post_connect_task and not self._post_connect_task.done():
self._post_connect_task.cancel()
try:
await self._post_connect_task
except asyncio.CancelledError:
pass
self._running = False
self._client = None
self._ready_event.clear()
self._post_connect_task = None
self._release_platform_lock()
logger.info("[%s] Disconnected", self.name)
async def _run_post_connect_initialization(self) -> None:
"""Finish non-critical startup work after Discord is connected."""
if not self._client:
return
try:
synced = await asyncio.wait_for(self._client.tree.sync(), timeout=30)
logger.info("[%s] Synced %d slash command(s)", self.name, len(synced))
except asyncio.TimeoutError:
logger.warning("[%s] Slash command sync timed out after 30s", self.name)
except asyncio.CancelledError:
raise
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[%s] Slash command sync failed: %s", self.name, e, exc_info=True)
async def _add_reaction(self, message: Any, emoji: str) -> bool:
"""Add an emoji reaction to a Discord message."""
if not message or not hasattr(message, "add_reaction"):
+176 -55
View File
@@ -18,6 +18,7 @@ Environment variables:
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
"""
@@ -104,7 +105,7 @@ MAX_MESSAGE_LENGTH = 4000
# Uses get_hermes_home() so each profile gets its own Matrix store.
from hermes_constants import get_hermes_dir as _get_hermes_dir
_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
_CRYPTO_PICKLE_PATH = _STORE_DIR / "crypto_store.pickle"
_CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
# Grace period: ignore messages older than this many seconds before startup.
_STARTUP_GRACE_SECONDS = 5
@@ -165,6 +166,33 @@ def check_matrix_requirements() -> bool:
return True
class _CryptoStateStore:
"""Adapter that satisfies the mautrix crypto StateStore interface.
OlmMachine requires a StateStore with ``is_encrypted``,
``get_encryption_info``, and ``find_shared_rooms``. The basic
``MemoryStateStore`` from ``mautrix.client`` doesn't implement these,
so we provide simple implementations that consult the client's room
state.
"""
def __init__(self, client_state_store: Any, joined_rooms: set):
self._ss = client_state_store
self._joined_rooms = joined_rooms
async def is_encrypted(self, room_id: str) -> bool:
return (await self.get_encryption_info(room_id)) is not None
async def get_encryption_info(self, room_id: str):
if hasattr(self._ss, "get_encryption_info"):
return await self._ss.get_encryption_info(room_id)
return None
async def find_shared_rooms(self, user_id: str) -> list:
# Return all joined rooms — simple but correct for a single-user bot.
return list(self._joined_rooms)
class MatrixAdapter(BasePlatformAdapter):
"""Gateway adapter for Matrix (any homeserver)."""
@@ -199,6 +227,7 @@ class MatrixAdapter(BasePlatformAdapter):
)
self._client: Any = None # mautrix.client.Client
self._crypto_db: Any = None # mautrix.util.async_db.Database
self._sync_task: Optional[asyncio.Task] = None
self._closing = False
self._startup_ts: float = 0.0
@@ -252,6 +281,92 @@ class MatrixAdapter(BasePlatformAdapter):
self._processed_events_set.add(event_id)
return False
# ------------------------------------------------------------------
# E2EE helpers
# ------------------------------------------------------------------
async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:
"""Verify our device keys are on the homeserver after loading crypto state.
Returns True if keys are valid or were successfully re-uploaded.
Returns False if verification fails (caller should refuse E2EE).
"""
try:
resp = await client.query_keys({client.mxid: [client.device_id]})
except Exception as exc:
logger.error(
"Matrix: cannot verify device keys on server: %s — refusing E2EE", exc,
)
return False
# query_keys returns typed objects (QueryKeysResponse, DeviceKeys
# with KeyID keys). Normalise to plain strings for comparison.
device_keys_map = getattr(resp, "device_keys", {}) or {}
our_user_devices = device_keys_map.get(str(client.mxid)) or {}
our_keys = our_user_devices.get(str(client.device_id))
if not our_keys:
logger.warning("Matrix: device keys missing from server — re-uploading")
olm.account.shared = False
try:
await olm.share_keys()
except Exception as exc:
logger.error("Matrix: failed to re-upload device keys: %s", exc)
return False
return True
# DeviceKeys.keys is a dict[KeyID, str]. Iterate to find the
# ed25519 key rather than constructing a KeyID for lookup.
server_ed25519 = None
keys_dict = getattr(our_keys, "keys", {}) or {}
for key_id, key_value in keys_dict.items():
if str(key_id).startswith("ed25519:"):
server_ed25519 = str(key_value)
break
local_ed25519 = olm.account.identity_keys.get("ed25519")
if server_ed25519 != local_ed25519:
if olm.account.shared:
# Restored account from DB but server has different keys — corrupted state.
logger.error(
"Matrix: server has different identity keys for device %s"
"local crypto state is stale. Delete %s and restart.",
client.device_id,
_CRYPTO_DB_PATH,
)
return False
# Fresh account (never uploaded). Server has stale keys from a
# previous installation. Try to delete the old device and re-upload.
logger.warning(
"Matrix: server has stale keys for device %s — attempting re-upload",
client.device_id,
)
try:
await client.api.request(
client.api.Method.DELETE
if hasattr(client.api, "Method")
else "DELETE",
f"/_matrix/client/v3/devices/{client.device_id}",
)
logger.info("Matrix: deleted stale device %s from server", client.device_id)
except Exception:
# Device deletion often requires UIA or may simply not be
# permitted — that's fine, share_keys will try to overwrite.
pass
try:
await olm.share_keys()
except Exception as exc:
logger.error(
"Matrix: cannot upload device keys for %s: %s. "
"Try generating a new access token to get a fresh device.",
client.device_id,
exc,
)
return False
return True
# ------------------------------------------------------------------
# Required overrides
# ------------------------------------------------------------------
@@ -350,54 +465,67 @@ class MatrixAdapter(BasePlatformAdapter):
return False
try:
from mautrix.crypto import OlmMachine
from mautrix.crypto.store import MemoryCryptoStore
from mautrix.crypto.store.asyncpg import PgCryptoStore
from mautrix.util.async_db import Database
_STORE_DIR.mkdir(parents=True, exist_ok=True)
# Remove legacy pickle file from pre-SQLite era.
legacy_pickle = _STORE_DIR / "crypto_store.pickle"
if legacy_pickle.exists():
logger.info("Matrix: removing legacy crypto_store.pickle (migrated to SQLite)")
legacy_pickle.unlink()
# Open SQLite-backed crypto store.
crypto_db = Database.create(
f"sqlite:///{_CRYPTO_DB_PATH}",
upgrade_table=PgCryptoStore.upgrade_table,
)
await crypto_db.start()
self._crypto_db = crypto_db
# account_id and pickle_key are required by mautrix ≥0.21.
# Use the Matrix user ID as account_id for stable identity.
# pickle_key secures in-memory serialisation; derive from
# the same user_id:device_id pair used for the on-disk HMAC.
_acct_id = self._user_id or "hermes"
_pickle_key = f"{_acct_id}:{self._device_id}"
crypto_store = MemoryCryptoStore(
_pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
crypto_store = PgCryptoStore(
account_id=_acct_id,
pickle_key=_pickle_key,
db=crypto_db,
)
await crypto_store.open()
# Restore persisted crypto state from a previous run.
# Uses HMAC to verify integrity before unpickling.
pickle_path = _CRYPTO_PICKLE_PATH
if pickle_path.exists():
try:
import hashlib, hmac, pickle
raw = pickle_path.read_bytes()
# Format: 32-byte HMAC-SHA256 signature + pickle data.
if len(raw) > 32:
sig, payload = raw[:32], raw[32:]
# Key is derived from the device_id + user_id (stable per install).
hmac_key = f"{self._user_id}:{self._device_id}".encode()
expected = hmac.new(hmac_key, payload, hashlib.sha256).digest()
if hmac.compare_digest(sig, expected):
saved = pickle.loads(payload) # noqa: S301
if isinstance(saved, MemoryCryptoStore):
crypto_store = saved
logger.info("Matrix: restored E2EE crypto store from %s", pickle_path)
else:
logger.warning("Matrix: crypto store HMAC mismatch — ignoring stale/tampered file")
except Exception as exc:
logger.warning("Matrix: could not restore crypto store: %s", exc)
crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
olm = OlmMachine(client, crypto_store, crypto_state)
olm = OlmMachine(client, crypto_store, state_store)
# Set trust policy: accept unverified devices so senders
# share Megolm session keys with us automatically.
# Accept unverified devices so senders share Megolm
# session keys with us automatically.
olm.share_keys_min_trust = TrustState.UNVERIFIED
olm.send_keys_min_trust = TrustState.UNVERIFIED
await olm.load()
# Verify our device keys are still on the homeserver.
if not await self._verify_device_keys_on_server(client, olm):
await crypto_db.stop()
await api.session.close()
return False
# Import cross-signing private keys from SSSS and self-sign
# the current device. Required after any device-key rotation
# (fresh crypto.db, share_keys re-upload) — otherwise the
# device's self-signing signature is stale and peers refuse
# to share Megolm sessions with the rotated device.
recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
if recovery_key:
try:
await olm.verify_with_recovery_key(recovery_key)
logger.info("Matrix: cross-signing verified via recovery key")
except Exception as exc:
logger.warning("Matrix: recovery key verification failed: %s", exc)
client.crypto = olm
logger.info(
"Matrix: E2EE enabled (store: %s%s)",
str(_STORE_DIR),
str(_CRYPTO_DB_PATH),
f", device_id={client.device_id}" if client.device_id else "",
)
except Exception as exc:
@@ -438,6 +566,15 @@ class MatrixAdapter(BasePlatformAdapter):
)
# Build DM room cache from m.direct account data.
await self._refresh_dm_cache()
# Dispatch events from the initial sync so the OlmMachine
# receives to-device key shares queued while we were offline.
try:
tasks = client.handle_sync(sync_data)
if tasks:
await asyncio.gather(*tasks)
except Exception as exc:
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
else:
logger.warning("Matrix: initial sync returned unexpected type %s", type(sync_data).__name__)
except Exception as exc:
@@ -466,21 +603,12 @@ class MatrixAdapter(BasePlatformAdapter):
except (asyncio.CancelledError, Exception):
pass
# Persist E2EE crypto store before closing so the next restart
# can decrypt events using sessions from this run.
if self._client and self._encryption and getattr(self._client, "crypto", None):
# Close the SQLite crypto store database.
if hasattr(self, "_crypto_db") and self._crypto_db:
try:
import hashlib, hmac, pickle
crypto_store = self._client.crypto.crypto_store
_STORE_DIR.mkdir(parents=True, exist_ok=True)
pickle_path = _CRYPTO_PICKLE_PATH
payload = pickle.dumps(crypto_store)
hmac_key = f"{self._user_id}:{self._device_id}".encode()
sig = hmac.new(hmac_key, payload, hashlib.sha256).digest()
pickle_path.write_bytes(sig + payload)
logger.info("Matrix: persisted E2EE crypto store to %s", pickle_path)
await self._crypto_db.stop()
except Exception as exc:
logger.debug("Matrix: could not persist crypto store on disconnect: %s", exc)
logger.debug("Matrix: could not close crypto DB on disconnect: %s", exc)
if self._client:
try:
@@ -853,13 +981,6 @@ class MatrixAdapter(BasePlatformAdapter):
except Exception as exc:
logger.warning("Matrix: sync event dispatch error: %s", exc)
# Share keys periodically if E2EE is enabled.
if self._encryption and getattr(client, "crypto", None):
try:
await client.crypto.share_keys()
except Exception as exc:
logger.warning("Matrix: E2EE key share failed: %s", exc)
# Retry any buffered undecrypted events.
if self._pending_megolm:
await self._retry_pending_decryptions()
+44 -2
View File
@@ -734,6 +734,42 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]:
return [unit for unit in units if unit]
def _looks_like_chatty_line_for_weixin(line: str) -> bool:
"""Return True when a line looks like a standalone chat utterance."""
stripped = line.strip()
if not stripped:
return False
if len(stripped) > 48:
return False
if line.startswith((" ", "\t")):
return False
if stripped.startswith((">", "-", "*", "")):
return False
if re.match(r"^\*\*[^*]+\*\*$", stripped):
return False
if re.match(r"^\d+\.\s", stripped):
return False
return True
def _looks_like_heading_line_for_weixin(line: str) -> bool:
"""Return True when a short line behaves like a plain-text heading."""
stripped = line.strip()
if not stripped:
return False
return len(stripped) <= 24 and stripped.endswith((":", ""))
def _should_split_short_chat_block_for_weixin(block: str) -> bool:
"""Split only chat-like multiline blocks into separate bubbles."""
lines = [line for line in block.splitlines() if line.strip()]
if not 2 <= len(lines) <= 6:
return False
if _looks_like_heading_line_for_weixin(lines[0]):
return False
return all(_looks_like_chatty_line_for_weixin(line) for line in lines)
def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]:
if len(content) <= max_length:
return [content]
@@ -787,9 +823,15 @@ def _split_text_for_weixin_delivery(
chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
return chunks or [content]
# Compact (default): single message when under the limit.
# Compact (default): single message when under the limit — unless the
# content looks like a short chatty exchange, in which case split into
# separate bubbles for a more natural chat feel.
if len(content) <= max_length:
return [content]
return (
_split_delivery_units_for_weixin(content)
if _should_split_short_chat_block_for_weixin(content)
else [content]
)
return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
+288 -32
View File
@@ -206,6 +206,15 @@ if _config_path.exists():
except Exception:
pass # Non-fatal; gateway can still run with .env values
# Apply IPv4 preference if configured (before any HTTP clients are created).
try:
from hermes_constants import apply_ipv4_preference
_network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {})
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception:
pass
# Validate config structure early — log warnings so gateway operators see problems
try:
from hermes_cli.config import print_config_warnings
@@ -867,13 +876,47 @@ class GatewayRunner:
"api_mode": override.get("api_mode"),
}
if override_runtime.get("api_key"):
logger.debug(
"Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s",
(resolved_session_key or "")[:30], model, override_model,
override_runtime.get("provider"),
)
return override_model, override_runtime
# Override exists but has no api_key — fall through to env-based
# resolution and apply model/provider from the override on top.
logger.debug(
"Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
(resolved_session_key or "")[:30], model, override_model,
)
else:
logger.debug(
"No session model override: session=%s config_model=%s override_keys=%s",
(resolved_session_key or "")[:30], model,
list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]",
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if override and resolved_session_key:
model, runtime_kwargs = self._apply_session_model_override(
resolved_session_key, model, runtime_kwargs
)
# When the config has no model.default but a provider was resolved
# (e.g. user ran `hermes auth add openai-codex` without `hermes model`),
# fall back to the provider's first catalog model so the API call
# doesn't fail with "model must be a non-empty string".
if not model and runtime_kwargs.get("provider"):
try:
from hermes_cli.models import get_default_model_for_provider
model = get_default_model_for_provider(runtime_kwargs["provider"])
if model:
logger.info(
"No model configured — defaulting to %s for provider %s",
model, runtime_kwargs["provider"],
)
except Exception:
pass
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
@@ -916,6 +959,12 @@ class GatewayRunner:
adapter.fatal_error_code or "unknown",
adapter.fatal_error_message or "unknown error",
)
self._update_platform_runtime_status(
adapter.platform.value,
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
existing = self.adapters.get(adapter.platform)
if existing is adapter:
@@ -993,6 +1042,25 @@ class GatewayRunner:
)
except Exception:
pass
def _update_platform_runtime_status(
self,
platform: str,
*,
platform_state: Optional[str] = None,
error_code: Optional[str] = None,
error_message: Optional[str] = None,
) -> None:
try:
from gateway.status import write_runtime_status
write_runtime_status(
platform=platform,
platform_state=platform_state,
error_code=error_code,
error_message=error_message,
)
except Exception:
pass
@staticmethod
def _load_prefill_messages() -> List[Dict[str, Any]]:
@@ -1467,12 +1535,25 @@ class GatewayRunner:
# This prevents stuck sessions from being blindly resumed on restart,
# which can create an unrecoverable loop (#7536). Suspended sessions
# auto-reset on the next incoming message, giving the user a clean start.
try:
suspended = self.session_store.suspend_recently_active()
if suspended:
logger.info("Suspended %d in-flight session(s) from previous run", suspended)
except Exception as e:
logger.warning("Session suspension on startup failed: %s", e)
#
# SKIP suspension after a clean (graceful) shutdown — the previous
# process already drained active agents, so sessions aren't stuck.
# This prevents unwanted auto-resets after `hermes update`,
# `hermes gateway restart`, or `/restart`.
_clean_marker = _hermes_home / ".clean_shutdown"
if _clean_marker.exists():
logger.info("Previous gateway exited cleanly — skipping session suspension")
try:
_clean_marker.unlink()
except Exception:
pass
else:
try:
suspended = self.session_store.suspend_recently_active()
if suspended:
logger.info("Suspended %d in-flight session(s) from previous run", suspended)
except Exception as e:
logger.warning("Session suspension on startup failed: %s", e)
connected_count = 0
enabled_platform_count = 0
@@ -1498,16 +1579,34 @@ class GatewayRunner:
# Try to connect
logger.info("Connecting to %s...", platform.value)
self._update_platform_runtime_status(
platform.value,
platform_state="connecting",
error_code=None,
error_message=None,
)
try:
success = await adapter.connect()
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
connected_count += 1
self._update_platform_runtime_status(
platform.value,
platform_state="connected",
error_code=None,
error_message=None,
)
logger.info("%s connected", platform.value)
else:
logger.warning("%s failed to connect", platform.value)
if adapter.has_fatal_error:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
target = (
startup_retryable_errors
if adapter.fatal_error_retryable
@@ -1524,6 +1623,12 @@ class GatewayRunner:
"next_retry": time.monotonic() + 30,
}
else:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message="failed to connect",
)
startup_retryable_errors.append(
f"{platform.value}: failed to connect"
)
@@ -1535,6 +1640,12 @@ class GatewayRunner:
}
except Exception as e:
logger.error("%s error: %s", platform.value, e)
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message=str(e),
)
startup_retryable_errors.append(f"{platform.value}: {e}")
# Unexpected exceptions are typically transient — queue for retry
self._failed_platforms[platform] = {
@@ -1813,6 +1924,12 @@ class GatewayRunner:
self._sync_voice_mode_state_to_adapter(adapter)
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
self._update_platform_runtime_status(
platform.value,
platform_state="connected",
error_code=None,
error_message=None,
)
logger.info("%s reconnected successfully", platform.value)
# Rebuild channel directory with the new adapter
@@ -1824,12 +1941,24 @@ class GatewayRunner:
else:
# Check if the failure is non-retryable
if adapter.has_fatal_error and not adapter.fatal_error_retryable:
self._update_platform_runtime_status(
platform.value,
platform_state="fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
logger.warning(
"Reconnect %s: non-retryable error (%s), removing from retry queue",
platform.value, adapter.fatal_error_message,
)
del self._failed_platforms[platform]
else:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message or "failed to reconnect",
)
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
info["attempts"] = attempt
info["next_retry"] = time.monotonic() + backoff
@@ -1838,6 +1967,12 @@ class GatewayRunner:
platform.value, backoff,
)
except Exception as e:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message=str(e),
)
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
info["attempts"] = attempt
info["next_retry"] = time.monotonic() + backoff
@@ -1944,6 +2079,15 @@ class GatewayRunner:
from gateway.status import remove_pid_file
remove_pid_file()
# Write a clean-shutdown marker so the next startup knows this
# wasn't a crash. suspend_recently_active() only needs to run
# after unexpected exits — graceful shutdowns already drain
# active agents, so there's no stuck-session risk.
try:
(_hermes_home / ".clean_shutdown").touch()
except Exception:
pass
if self._restart_requested and self._restart_via_service:
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
self._exit_reason = self._exit_reason or "Gateway restart requested"
@@ -3877,9 +4021,16 @@ class GatewayRunner:
except Exception:
pass
# Append a random tip to the reset message
try:
from hermes_cli.tips import get_random_tip
_tip_line = f"\n✦ Tip: {get_random_tip()}"
except Exception:
_tip_line = ""
if session_info:
return f"{header}\n\n{session_info}"
return header
return f"{header}\n\n{session_info}{_tip_line}"
return f"{header}{_tip_line}"
async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile — show active profile name and home directory."""
@@ -4209,6 +4360,11 @@ class GatewayRunner:
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh
# agent from the override rather than relying on the
# stale cache signature to trigger a rebuild.
_self._evict_cached_agent(_session_key)
# Build confirmation text
plabel = result.provider_label or result.target_provider
lines = [f"Model switched to `{result.new_model}`"]
@@ -4322,6 +4478,10 @@ class GatewayRunner:
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Persist to config if --global
if persist_global:
try:
@@ -5637,7 +5797,12 @@ class GatewayRunner:
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
"""Handle /compress command -- manually compress conversation context.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else.
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(session_entry.session_id)
@@ -5645,6 +5810,9 @@ class GatewayRunner:
if not history or len(history) < 4:
return "Not enough conversation to compress (need at least 4 messages)."
# Extract optional focus topic from command args
focus_topic = (event.get_command_args() or "").strip() or None
try:
from run_agent import AIAgent
from agent.manual_compression_feedback import summarize_manual_compression
@@ -5686,7 +5854,7 @@ class GatewayRunner:
loop = asyncio.get_event_loop()
compressed, _ = await loop.run_in_executor(
None,
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens)
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic)
)
# _compress_context already calls end_session() on the old session
@@ -5710,7 +5878,10 @@ class GatewayRunner:
approx_tokens,
new_tokens,
)
lines = [f"🗜️ {summary['headline']}", summary["token_line"]]
lines = [f"🗜️ {summary['headline']}"]
if focus_topic:
lines.append(f"Focus: \"{focus_topic}\"")
lines.append(summary["token_line"])
if summary["note"]:
lines.append(summary["note"])
return "\n".join(lines)
@@ -6497,8 +6668,12 @@ class GatewayRunner:
if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval:
await _flush_buffer()
# Check for prompts
if prompt_path.exists() and session_key:
# Check for prompts — only forward if we haven't already sent
# one that's still awaiting a response. Without this guard the
# watcher would re-read the same .update_prompt.json every poll
# cycle and spam the user with duplicate prompt messages.
if (prompt_path.exists() and session_key
and not self._update_prompt_pending.get(session_key)):
try:
prompt_data = json.loads(prompt_path.read_text())
prompt_text = prompt_data.get("prompt", "")
@@ -6530,6 +6705,11 @@ class GatewayRunner:
f"or type your answer directly."
)
self._update_prompt_pending[session_key] = True
# Remove the prompt file so it isn't re-read on the
# next poll cycle. The update process only needs
# .update_response to continue — it doesn't re-check
# .update_prompt.json while waiting.
prompt_path.unlink(missing_ok=True)
logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80])
except (json.JSONDecodeError, OSError) as e:
logger.debug("Failed to read update prompt: %s", e)
@@ -6903,7 +7083,9 @@ class GatewayRunner:
if session.exited:
# --- Agent-triggered completion: inject synthetic message ---
if agent_notify:
# Skip if the agent already consumed the result via wait/poll/log
from tools.process_registry import process_registry as _pr_check
if agent_notify and not _pr_check.is_completion_consumed(session_id):
from tools.ansi_strip import strip_ansi
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
synth_text = (
@@ -7437,6 +7619,10 @@ class GatewayRunner:
session_key=session_key,
user_config=user_config,
)
logger.debug(
"run_agent resolved: model=%s provider=%s session=%s",
model, runtime_kwargs.get("provider"), (session_key or "")[:30],
)
except Exception as exc:
return {
"final_response": f"⚠️ Provider authentication failed: {exc}",
@@ -7911,26 +8097,51 @@ class GatewayRunner:
tracking_task = asyncio.create_task(track_agent())
# Monitor for interrupts from the adapter (new messages arriving)
# Monitor for interrupts from the adapter (new messages arriving).
# This is the PRIMARY interrupt path for regular text messages —
# Level 1 (base.py) catches them before _handle_message() is reached,
# so the Level 2 running_agent.interrupt() path never fires.
# The inactivity poll loop below has a BACKUP check in case this
# task dies (no error handling = silent death = lost interrupts).
_interrupt_detected = asyncio.Event() # shared with backup check
async def monitor_for_interrupt():
adapter = self.adapters.get(source.platform)
if not adapter or not session_key:
if not session_key:
return
while True:
await asyncio.sleep(0.2) # Check every 200ms
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
pending_event = adapter.get_pending_message(session_key)
pending_text = pending_event.text if pending_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
break
try:
# Re-resolve adapter each iteration so reconnects don't
# leave us holding a stale reference.
_adapter = self.adapters.get(source.platform)
if not _adapter:
continue
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
# Peek at the pending message text WITHOUT consuming it.
# The message must remain in _pending_messages so the
# post-run dequeue at _dequeue_pending_event() can
# retrieve the full MessageEvent (with media metadata).
# If we pop here, a race exists: the agent may finish
# before checking _interrupt_requested, and the message
# is lost — neither the interrupt path nor the dequeue
# path finds it.
_peek_event = _adapter._pending_messages.get(session_key)
pending_text = _peek_event.text if _peek_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
_interrupt_detected.set()
break
except asyncio.CancelledError:
raise
except Exception as _mon_err:
logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err)
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
@@ -7995,8 +8206,34 @@ class GatewayRunner:
_POLL_INTERVAL = 5.0
if _agent_timeout is None:
# Unlimited — just await the result.
response = await _executor_task
# Unlimited — still poll periodically for backup interrupt
# detection in case monitor_for_interrupt() silently died.
response = None
while True:
done, _ = await asyncio.wait(
{_executor_task}, timeout=_POLL_INTERVAL
)
if done:
response = _executor_task.result()
break
# Backup interrupt check: if the monitor task died or
# missed the interrupt, catch it here.
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter._pending_messages.get(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
else:
# Poll loop: check the agent's built-in activity tracker
# (updated by _touch_activity() on every tool call, API
@@ -8040,6 +8277,23 @@ class GatewayRunner:
if _idle_secs >= _agent_timeout:
_inactivity_timeout = True
break
# Backup interrupt check (same as unlimited path).
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter._pending_messages.get(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
if _inactivity_timeout:
# Build a diagnostic summary from the agent's activity tracker.
@@ -8470,6 +8724,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# verbosity=1 (-v): INFO and above
# verbosity=2+ (-vv/-vvv): DEBUG
if verbosity is not None:
from agent.redact import RedactingFormatter
_stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
_stderr_handler = logging.StreamHandler()
_stderr_handler.setLevel(_stderr_level)
+2 -2
View File
@@ -807,9 +807,9 @@ class SessionStore:
to avoid resetting long-idle sessions that are harmless to resume.
Returns the number of sessions that were suspended.
"""
import time as _time
from datetime import timedelta
cutoff = _time.time() - max_age_seconds
cutoff = _now() - timedelta(seconds=max_age_seconds)
count = 0
with self._lock:
self._ensure_loaded_locked()
+17 -16
View File
@@ -26,6 +26,7 @@ _GATEWAY_KIND = "hermes-gateway"
_RUNTIME_STATUS_FILE = "gateway_state.json"
_LOCKS_DIRNAME = "gateway-locks"
_IS_WINDOWS = sys.platform == "win32"
_UNSET = object()
def _get_pid_path() -> Path:
@@ -218,14 +219,14 @@ def write_pid_file() -> None:
def write_runtime_status(
*,
gateway_state: Optional[str] = None,
exit_reason: Optional[str] = None,
restart_requested: Optional[bool] = None,
active_agents: Optional[int] = None,
platform: Optional[str] = None,
platform_state: Optional[str] = None,
error_code: Optional[str] = None,
error_message: Optional[str] = None,
gateway_state: Any = _UNSET,
exit_reason: Any = _UNSET,
restart_requested: Any = _UNSET,
active_agents: Any = _UNSET,
platform: Any = _UNSET,
platform_state: Any = _UNSET,
error_code: Any = _UNSET,
error_message: Any = _UNSET,
) -> None:
"""Persist gateway runtime health information for diagnostics/status."""
path = _get_runtime_status_path()
@@ -236,22 +237,22 @@ def write_runtime_status(
payload["start_time"] = _get_process_start_time(os.getpid())
payload["updated_at"] = _utc_now_iso()
if gateway_state is not None:
if gateway_state is not _UNSET:
payload["gateway_state"] = gateway_state
if exit_reason is not None:
if exit_reason is not _UNSET:
payload["exit_reason"] = exit_reason
if restart_requested is not None:
if restart_requested is not _UNSET:
payload["restart_requested"] = bool(restart_requested)
if active_agents is not None:
if active_agents is not _UNSET:
payload["active_agents"] = max(0, int(active_agents))
if platform is not None:
if platform is not _UNSET:
platform_payload = payload["platforms"].get(platform, {})
if platform_state is not None:
if platform_state is not _UNSET:
platform_payload["state"] = platform_state
if error_code is not None:
if error_code is not _UNSET:
platform_payload["error_code"] = error_code
if error_message is not None:
if error_message is not _UNSET:
platform_payload["error_message"] = error_message
platform_payload["updated_at"] = _utc_now_iso()
payload["platforms"][platform] = platform_payload
+49
View File
@@ -1303,6 +1303,49 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
}
def _write_codex_cli_tokens(
access_token: str,
refresh_token: str,
*,
last_refresh: Optional[str] = None,
) -> None:
"""Write refreshed tokens back to ~/.codex/auth.json.
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When Hermes refreshes a token it consumes the old refresh_token; if we
don't write the new pair back, the Codex CLI (or VS Code extension) will
fail with ``refresh_token_reused`` on its next refresh attempt.
This mirrors the Anthropic write-back to ~/.claude/.credentials.json
via ``_write_claude_code_credentials()``.
"""
codex_home = os.getenv("CODEX_HOME", "").strip()
if not codex_home:
codex_home = str(Path.home() / ".codex")
auth_path = Path(codex_home).expanduser() / "auth.json"
try:
existing: Dict[str, Any] = {}
if auth_path.is_file():
existing = json.loads(auth_path.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
tokens_dict = existing.get("tokens")
if not isinstance(tokens_dict, dict):
tokens_dict = {}
tokens_dict["access_token"] = access_token
tokens_dict["refresh_token"] = refresh_token
existing["tokens"] = tokens_dict
if last_refresh is not None:
existing["last_refresh"] = last_refresh
auth_path.parent.mkdir(parents=True, exist_ok=True)
auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
auth_path.chmod(0o600)
except (OSError, IOError) as exc:
logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc)
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
if last_refresh is None:
@@ -1425,6 +1468,12 @@ def _refresh_codex_auth_tokens(
updated_tokens["refresh_token"] = refreshed["refresh_token"]
_save_codex_tokens(updated_tokens)
# Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.
_write_codex_cli_tokens(
refreshed["access_token"],
refreshed["refresh_token"],
last_refresh=refreshed.get("last_refresh"),
)
return updated_tokens
+399
View File
@@ -0,0 +1,399 @@
"""
Backup and import commands for hermes CLI.
`hermes backup` creates a zip archive of the entire ~/.hermes/ directory
(excluding the hermes-agent repo and transient files).
`hermes import` restores from a backup zip, overlaying onto the current
HERMES_HOME root.
"""
import os
import sys
import time
import zipfile
from datetime import datetime
from pathlib import Path
from hermes_constants import get_default_hermes_root, display_hermes_home
# ---------------------------------------------------------------------------
# Exclusion rules
# ---------------------------------------------------------------------------
# Directory names to skip entirely (matched against each path component)
_EXCLUDED_DIRS = {
"hermes-agent", # the codebase repo — re-clone instead
"__pycache__", # bytecode caches — regenerated on import
".git", # nested git dirs (profiles shouldn't have these, but safety)
"node_modules", # js deps if website/ somehow leaks in
}
# File-name suffixes to skip
_EXCLUDED_SUFFIXES = (
".pyc",
".pyo",
)
# File names to skip (runtime state that's meaningless on another machine)
_EXCLUDED_NAMES = {
"gateway.pid",
"cron.pid",
}
def _should_exclude(rel_path: Path) -> bool:
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
parts = rel_path.parts
# Any path component matches an excluded dir name
for part in parts:
if part in _EXCLUDED_DIRS:
return True
name = rel_path.name
if name in _EXCLUDED_NAMES:
return True
if name.endswith(_EXCLUDED_SUFFIXES):
return True
return False
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
def _format_size(nbytes: int) -> str:
"""Human-readable file size."""
for unit in ("B", "KB", "MB", "GB"):
if nbytes < 1024:
return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
nbytes /= 1024
return f"{nbytes:.1f} TB"
def run_backup(args) -> None:
"""Create a zip backup of the Hermes home directory."""
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
print(f"Error: Hermes home directory not found at {hermes_root}")
sys.exit(1)
# Determine output path
if args.output:
out_path = Path(args.output).expanduser().resolve()
# If user gave a directory, put the zip inside it
if out_path.is_dir():
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
out_path = out_path / f"hermes-backup-{stamp}.zip"
else:
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
out_path = Path.home() / f"hermes-backup-{stamp}.zip"
# Ensure the suffix is .zip
if out_path.suffix.lower() != ".zip":
out_path = out_path.with_suffix(out_path.suffix + ".zip")
# Ensure parent directory exists
out_path.parent.mkdir(parents=True, exist_ok=True)
# Collect files
print(f"Scanning {display_hermes_home()} ...")
files_to_add: list[tuple[Path, Path]] = [] # (absolute, relative)
skipped_dirs = set()
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
dp = Path(dirpath)
rel_dir = dp.relative_to(hermes_root)
# Prune excluded directories in-place so os.walk doesn't descend
orig_dirnames = dirnames[:]
dirnames[:] = [
d for d in dirnames
if d not in _EXCLUDED_DIRS
]
for removed in set(orig_dirnames) - set(dirnames):
skipped_dirs.add(str(rel_dir / removed))
for fname in filenames:
fpath = dp / fname
rel = fpath.relative_to(hermes_root)
if _should_exclude(rel):
continue
# Skip the output zip itself if it happens to be inside hermes root
try:
if fpath.resolve() == out_path.resolve():
continue
except (OSError, ValueError):
pass
files_to_add.append((fpath, rel))
if not files_to_add:
print("No files to back up.")
return
# Create the zip
file_count = len(files_to_add)
print(f"Backing up {file_count} files ...")
total_bytes = 0
errors = []
t0 = time.monotonic()
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
for i, (abs_path, rel_path) in enumerate(files_to_add, 1):
try:
zf.write(abs_path, arcname=str(rel_path))
total_bytes += abs_path.stat().st_size
except (PermissionError, OSError) as exc:
errors.append(f" {rel_path}: {exc}")
continue
# Progress every 500 files
if i % 500 == 0:
print(f" {i}/{file_count} files ...")
elapsed = time.monotonic() - t0
zip_size = out_path.stat().st_size
# Summary
print()
print(f"Backup complete: {out_path}")
print(f" Files: {file_count}")
print(f" Original: {_format_size(total_bytes)}")
print(f" Compressed: {_format_size(zip_size)}")
print(f" Time: {elapsed:.1f}s")
if skipped_dirs:
print(f"\n Excluded directories:")
for d in sorted(skipped_dirs):
print(f" {d}/")
if errors:
print(f"\n Warnings ({len(errors)} files skipped):")
for e in errors[:10]:
print(e)
if len(errors) > 10:
print(f" ... and {len(errors) - 10} more")
print(f"\nRestore with: hermes import {out_path.name}")
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
"""Check that a zip looks like a Hermes backup.
Returns (ok, reason).
"""
names = zf.namelist()
if not names:
return False, "zip archive is empty"
# Look for telltale files that a hermes home would have
markers = {"config.yaml", ".env", "hermes_state.db", "memory_store.db"}
found = set()
for n in names:
# Could be at the root or one level deep (if someone zipped the directory)
basename = Path(n).name
if basename in markers:
found.add(basename)
if not found:
return False, (
"zip does not appear to be a Hermes backup "
"(no config.yaml, .env, or state databases found)"
)
return True, ""
def _detect_prefix(zf: zipfile.ZipFile) -> str:
"""Detect if the zip has a common directory prefix wrapping all entries.
Some tools zip as `.hermes/config.yaml` instead of `config.yaml`.
Returns the prefix to strip (empty string if none).
"""
names = [n for n in zf.namelist() if not n.endswith("/")]
if not names:
return ""
# Find common prefix
parts_list = [Path(n).parts for n in names]
# Check if all entries share a common first directory
first_parts = {p[0] for p in parts_list if len(p) > 1}
if len(first_parts) == 1:
prefix = first_parts.pop()
# Only strip if it looks like a hermes dir name
if prefix in (".hermes", "hermes"):
return prefix + "/"
return ""
def run_import(args) -> None:
"""Restore a Hermes backup from a zip file."""
zip_path = Path(args.zipfile).expanduser().resolve()
if not zip_path.is_file():
print(f"Error: File not found: {zip_path}")
sys.exit(1)
if not zipfile.is_zipfile(zip_path):
print(f"Error: Not a valid zip file: {zip_path}")
sys.exit(1)
hermes_root = get_default_hermes_root()
with zipfile.ZipFile(zip_path, "r") as zf:
# Validate
ok, reason = _validate_backup_zip(zf)
if not ok:
print(f"Error: {reason}")
sys.exit(1)
prefix = _detect_prefix(zf)
members = [n for n in zf.namelist() if not n.endswith("/")]
file_count = len(members)
print(f"Backup contains {file_count} files")
print(f"Target: {display_hermes_home()}")
if prefix:
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
# Check for existing installation
has_config = (hermes_root / "config.yaml").exists()
has_env = (hermes_root / ".env").exists()
if (has_config or has_env) and not args.force:
print()
print("Warning: Target directory already has Hermes configuration.")
print("Importing will overwrite existing files with backup contents.")
print()
try:
answer = input("Continue? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\nAborted.")
sys.exit(1)
if answer not in ("y", "yes"):
print("Aborted.")
return
# Extract
print(f"\nImporting {file_count} files ...")
hermes_root.mkdir(parents=True, exist_ok=True)
errors = []
restored = 0
t0 = time.monotonic()
for member in members:
# Strip prefix if detected
if prefix and member.startswith(prefix):
rel = member[len(prefix):]
else:
rel = member
if not rel:
continue
target = hermes_root / rel
# Security: reject absolute paths and traversals
try:
target.resolve().relative_to(hermes_root.resolve())
except ValueError:
errors.append(f" {rel}: path traversal blocked")
continue
try:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f" {rel}: {exc}")
if restored % 500 == 0:
print(f" {restored}/{file_count} files ...")
elapsed = time.monotonic() - t0
# Summary
print()
print(f"Import complete: {restored} files restored in {elapsed:.1f}s")
print(f" Target: {display_hermes_home()}")
if errors:
print(f"\n Warnings ({len(errors)} files skipped):")
for e in errors[:10]:
print(e)
if len(errors) > 10:
print(f" ... and {len(errors) - 10} more")
# Post-import: restore profile wrapper scripts
profiles_dir = hermes_root / "profiles"
restored_profiles = []
if profiles_dir.is_dir():
try:
from hermes_cli.profiles import (
create_wrapper_script, check_alias_collision,
_is_wrapper_dir_in_path, _get_wrapper_dir,
)
for entry in sorted(profiles_dir.iterdir()):
if not entry.is_dir():
continue
profile_name = entry.name
# Only create wrappers for directories with config
if not (entry / "config.yaml").exists() and not (entry / ".env").exists():
continue
collision = check_alias_collision(profile_name)
if collision:
print(f" Skipped alias '{profile_name}': {collision}")
restored_profiles.append((profile_name, False))
else:
wrapper = create_wrapper_script(profile_name)
restored_profiles.append((profile_name, wrapper is not None))
if restored_profiles:
created = [n for n, ok in restored_profiles if ok]
skipped = [n for n, ok in restored_profiles if not ok]
if created:
print(f"\n Profile aliases restored: {', '.join(created)}")
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(' export PATH="$HOME/.local/bin:$PATH"')
except ImportError:
# hermes_cli.profiles might not be available (fresh install)
if any(profiles_dir.iterdir()):
print(f"\n Profiles detected but aliases could not be created.")
print(f" Run: hermes profile list (after installing hermes)")
# Guidance
print()
if not (hermes_root / "hermes-agent").is_dir():
print("Note: The hermes-agent codebase was not included in the backup.")
print(" If this is a fresh install, run: hermes update")
if restored_profiles:
gw_profiles = [n for n, _ in restored_profiles]
print("\nTo re-enable gateway services for profiles:")
for pname in gw_profiles:
print(f" hermes -p {pname} gateway install")
print("Done. Your Hermes configuration has been restored.")
+10 -59
View File
@@ -50,7 +50,7 @@ _OPENCLAW_SCRIPT_INSTALLED = (
)
# Known OpenClaw directory names (current + legacy)
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot")
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
def _warn_if_gateway_running(auto_yes: bool) -> None:
"""Check if a Hermes gateway is running with connected platforms.
@@ -87,8 +87,8 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
print_info("Migration cancelled. Stop the gateway and try again.")
sys.exit(0)
# State files commonly found in OpenClaw workspace directories that cause
# confusion after migration (the agent discovers them and writes to them)
# State files commonly found in OpenClaw workspace directories — listed
# during cleanup to help the user decide whether to archive
_WORKSPACE_STATE_GLOBS = (
"*/todo.json",
"*/sessions/*",
@@ -133,7 +133,7 @@ def _find_openclaw_dirs() -> list[Path]:
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""Scan an OpenClaw directory for workspace state files that cause confusion.
"""Scan an OpenClaw directory for workspace state files.
Returns a list of (path, description) tuples.
"""
@@ -216,7 +216,7 @@ def _cmd_migrate(args):
source_dir = Path.home() / ".openclaw"
if not source_dir.is_dir():
# Try legacy directory names
for legacy in (".clawdbot", ".moldbot"):
for legacy in (".clawdbot", ".moltbot"):
candidate = Path.home() / legacy
if candidate.is_dir():
source_dir = candidate
@@ -384,65 +384,16 @@ def _cmd_migrate(args):
# Print results
_print_migration_report(report, dry_run=False)
# After successful migration, offer to archive the source directory
if report.get("summary", {}).get("migrated", 0) > 0:
_offer_source_archival(source_dir, auto_yes)
def _offer_source_archival(source_dir: Path, auto_yes: bool = False):
"""After migration, offer to rename the source directory to prevent state fragmentation.
OpenClaw workspace directories contain state files (todo.json, sessions, etc.)
that the agent may discover and write to, causing confusion. Renaming the
directory prevents this.
"""
if not source_dir.is_dir():
return
# Scan for state files that could cause problems
state_files = _scan_workspace_state(source_dir)
print()
print_header("Post-Migration Cleanup")
print_info("The OpenClaw directory still exists and contains workspace state files")
print_info("that can confuse the agent (todo lists, sessions, logs).")
if state_files:
print()
print(color(" Found state files:", Colors.YELLOW))
# Show up to 10 most relevant findings
for path, desc in state_files[:10]:
print(f" {desc}")
if len(state_files) > 10:
print(f" ... and {len(state_files) - 10} more")
print()
print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/")
print_info("This prevents the agent from discovering old workspace directories.")
print_info("You can always rename it back if needed.")
print()
if not auto_yes and not sys.stdin.isatty():
print_info("Non-interactive session — skipping archival.")
print_info("Run later with: hermes claw cleanup")
return
if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True):
try:
archive_path = _archive_directory(source_dir)
print_success(f"Archived: {source_dir}{archive_path}")
print_info("The original directory has been renamed, not deleted.")
print_info(f"To undo: mv {archive_path} {source_dir}")
except OSError as e:
print_error(f"Could not archive: {e}")
print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration")
else:
print_info("Skipped. You can archive later with: hermes claw cleanup")
# Source directory is left untouched — archiving is not the migration
# tool's responsibility. Users who want to clean up can run
# 'hermes claw cleanup' separately.
def _cmd_cleanup(args):
"""Archive leftover OpenClaw directories after migration.
Scans for OpenClaw directories that still exist after migration and offers
to rename them to .pre-migration to prevent state fragmentation.
to rename them to .pre-migration to free disk space.
"""
dry_run = getattr(args, "dry_run", False)
auto_yes = getattr(args, "yes", False)
@@ -517,7 +468,7 @@ def _cmd_cleanup(args):
if state_files:
print()
print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW))
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
for path, desc in state_files[:8]:
print(f" {desc}")
if len(state_files) > 8:
+2 -1
View File
@@ -69,7 +69,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[name]"),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session"),
CommandDef("compress", "Manually compress conversation context", "Session",
args_hint="[focus topic]"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("stop", "Kill all running background processes", "Session"),
+19 -20
View File
@@ -50,6 +50,7 @@ _EXTRA_ENV_KEYS = frozenset({
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
})
import yaml
@@ -147,25 +148,6 @@ def managed_error(action: str = "modify configuration"):
# Container-aware CLI (NixOS container mode)
# =============================================================================
def _is_inside_container() -> bool:
"""Detect if we're already running inside a Docker/Podman container."""
# Standard Docker/Podman indicators
if os.path.exists("/.dockerenv"):
return True
# Podman uses /run/.containerenv
if os.path.exists("/run/.containerenv"):
return True
# Check cgroup for container runtime evidence (works for both Docker & Podman)
try:
with open("/proc/1/cgroup", "r") as f:
cgroup = f.read()
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
return True
except OSError:
pass
return False
def get_container_exec_info() -> Optional[dict]:
"""Read container mode metadata from HERMES_HOME/.container-mode.
@@ -180,7 +162,8 @@ def get_container_exec_info() -> Optional[dict]:
if os.environ.get("HERMES_DEV") == "1":
return None
if _is_inside_container():
from hermes_constants import is_container
if is_container():
return None
container_mode_file = get_hermes_home() / ".container-mode"
@@ -706,6 +689,14 @@ DEFAULT_CONFIG = {
"backup_count": 3, # Number of rotated backup files to keep
},
# Network settings — workarounds for connectivity issues.
"network": {
# Force IPv4 connections. On servers with broken or unreachable IPv6,
# Python tries AAAA records first and hangs for the full TCP timeout
# before falling back to IPv4. Set to true to skip IPv6 entirely.
"force_ipv4": False,
},
# Config schema version - bump this when adding new required fields
"_config_version": 16,
}
@@ -1285,6 +1276,14 @@ OPTIONAL_ENV_VARS = {
"category": "messaging",
"advanced": True,
},
"MATRIX_RECOVERY_KEY": {
"description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)",
"prompt": "Matrix recovery key",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"BLUEBUBBLES_SERVER_URL": {
"description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)",
"prompt": "BlueBubbles server URL",
+10
View File
@@ -44,6 +44,16 @@ def _redact(value: str) -> str:
def _gateway_status() -> str:
"""Return a short gateway status string."""
if sys.platform.startswith("linux"):
from hermes_constants import is_container
if is_container():
try:
from hermes_cli.gateway import find_gateway_pids
pids = find_gateway_pids()
if pids:
return f"running (docker, pid {pids[0]})"
return "stopped (docker)"
except Exception:
return "stopped (docker)"
try:
from hermes_cli.gateway import get_service_name
svc = get_service_name()
+70 -26
View File
@@ -331,7 +331,7 @@ def is_linux() -> bool:
return sys.platform.startswith('linux')
from hermes_constants import is_termux, is_wsl
from hermes_constants import is_container, is_termux, is_wsl
def _wsl_systemd_operational() -> bool:
@@ -353,7 +353,9 @@ def _wsl_systemd_operational() -> bool:
def supports_systemd_services() -> bool:
if not is_linux() or is_termux():
if not is_linux() or is_termux() or is_container():
return False
if shutil.which("systemctl") is None:
return False
if is_wsl():
return _wsl_systemd_operational()
@@ -483,6 +485,21 @@ def _journalctl_cmd(system: bool = False) -> list[str]:
return ["journalctl"] if system else ["journalctl", "--user"]
def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess:
"""Run a systemctl command, raising RuntimeError if systemctl is missing.
Defense-in-depth: callers are gated by ``supports_systemd_services()``,
but this ensures any future caller that bypasses the gate still gets a
clear error instead of a raw ``FileNotFoundError`` traceback.
"""
try:
return subprocess.run(_systemctl_cmd(system) + args, **kwargs)
except FileNotFoundError:
raise RuntimeError(
"systemctl is not available on this system"
) from None
def _service_scope_label(system: bool = False) -> str:
return "system" if system else "user"
@@ -929,7 +946,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install")
return True
@@ -1025,7 +1042,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
if not systemd_unit_is_current(system=system):
print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
_run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service definition updated")
return
print(f"Service already installed at: {unit_path}")
@@ -1036,8 +1053,8 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
_run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30)
print()
print(f"{_service_scope_label(system).capitalize()} service installed and enabled!")
@@ -1063,15 +1080,15 @@ def systemd_uninstall(system: bool = False):
if system:
_require_root_for_system_service("uninstall")
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False, timeout=90)
subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False, timeout=30)
_run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90)
_run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30)
unit_path = get_systemd_unit_path(system=system)
if unit_path.exists():
unit_path.unlink()
print(f"✓ Removed {unit_path}")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service uninstalled")
@@ -1080,7 +1097,7 @@ def systemd_start(system: bool = False):
if system:
_require_root_for_system_service("start")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True, timeout=30)
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service started")
@@ -1089,7 +1106,7 @@ def systemd_stop(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("stop")
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True, timeout=90)
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service stopped")
@@ -1105,7 +1122,7 @@ def systemd_restart(system: bool = False):
if pid is not None and _request_gateway_self_restart(pid):
print(f"{_service_scope_label(system).capitalize()} service restart requested")
return
subprocess.run(_systemctl_cmd(system) + ["reload-or-restart", get_service_name()], check=True, timeout=90)
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service restarted")
@@ -1129,14 +1146,16 @@ def systemd_status(deep: bool = False, system: bool = False):
print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
print()
subprocess.run(
_systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"],
_run_systemctl(
["status", get_service_name(), "--no-pager"],
system=system,
capture_output=False,
timeout=10,
)
result = subprocess.run(
_systemctl_cmd(system) + ["is-active", get_service_name()],
result = _run_systemctl(
["is-active", get_service_name()],
system=system,
capture_output=True,
text=True,
timeout=10,
@@ -2129,24 +2148,24 @@ def _is_service_running() -> bool:
if user_unit_exists:
try:
result = subprocess.run(
_systemctl_cmd(False) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
result = _run_systemctl(
["is-active", get_service_name()],
system=False, capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
except (RuntimeError, subprocess.TimeoutExpired):
pass
if system_unit_exists:
try:
result = subprocess.run(
_systemctl_cmd(True) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
result = _run_systemctl(
["is-active", get_service_name()],
system=True, capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
except (RuntimeError, subprocess.TimeoutExpired):
pass
return False
@@ -2606,6 +2625,15 @@ def gateway_command(args):
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
sys.exit(1)
elif is_container():
print("Service installation is not needed inside a Docker container.")
print("The container runtime is your service manager — use Docker restart policies instead:")
print()
print(" docker run --restart unless-stopped ... # auto-restart on crash/reboot")
print(" docker restart <container> # manual restart")
print()
print("To run the gateway: hermes gateway run")
sys.exit(0)
else:
print("Service installation not supported on this platform.")
print("Run manually: hermes gateway run")
@@ -2624,10 +2652,17 @@ def gateway_command(args):
systemd_uninstall(system=system)
elif is_macos():
launchd_uninstall()
elif is_container():
print("Service uninstall is not applicable inside a Docker container.")
print("To stop the gateway, stop or remove the container:")
print()
print(" docker stop <container>")
print(" docker rm <container>")
sys.exit(0)
else:
print("Not supported on this platform.")
sys.exit(1)
elif subcmd == "start":
system = getattr(args, 'system', False)
if is_termux():
@@ -2648,10 +2683,19 @@ def gateway_command(args):
print()
print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.")
sys.exit(1)
elif is_container():
print("Service start is not applicable inside a Docker container.")
print("The gateway runs as the container's main process.")
print()
print(" docker start <container> # start a stopped container")
print(" docker restart <container> # restart a running container")
print()
print("Or run the gateway directly: hermes gateway run")
sys.exit(0)
else:
print("Not supported on this platform.")
sys.exit(1)
elif subcmd == "stop":
stop_all = getattr(args, 'all', False)
system = getattr(args, 'system', False)
+93 -3
View File
@@ -151,6 +151,18 @@ try:
except Exception:
pass # best-effort — don't crash the CLI if logging setup fails
# Apply IPv4 preference early, before any HTTP clients are created.
try:
from hermes_cli.config import load_config as _load_config_early
from hermes_constants import apply_ipv4_preference as _apply_ipv4
_early_cfg = _load_config_early()
_net = _early_cfg.get("network", {})
if isinstance(_net, dict) and _net.get("force_ipv4"):
_apply_ipv4(force=True)
del _early_cfg, _net
except Exception:
pass # best-effort — don't crash if config isn't available yet
import logging
import time as _time
from datetime import datetime
@@ -1095,6 +1107,7 @@ def select_provider_and_model(args=None):
"base_url": base_url,
"api_key": entry.get("api_key", ""),
"model": entry.get("model", ""),
"api_mode": entry.get("api_mode", ""),
}
return custom_provider_map
@@ -1943,6 +1956,12 @@ def _model_flow_named_custom(config, provider_info):
model["base_url"] = base_url
if api_key:
model["api_key"] = api_key
# Apply api_mode from custom_providers entry, or clear stale value
custom_api_mode = provider_info.get("api_mode", "")
if custom_api_mode:
model["api_mode"] = custom_api_mode
else:
model.pop("api_mode", None) # let runtime auto-detect from URL
save_config(cfg)
deactivate_provider()
@@ -2480,8 +2499,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
print()
override = ""
if override and base_url_env:
save_env_value(base_url_env, override)
effective_base = override
if not override.startswith(("http://", "https://")):
print(" Invalid URL — must start with http:// or https://. Keeping current value.")
else:
save_env_value(base_url_env, override)
effective_base = override
# Model selection — resolution order:
# 1. models.dev registry (cached, filtered for agentic/tool-capable models)
@@ -2818,6 +2840,18 @@ def cmd_config(args):
config_command(args)
def cmd_backup(args):
"""Back up Hermes home directory to a zip file."""
from hermes_cli.backup import run_backup
run_backup(args)
def cmd_import(args):
"""Restore a Hermes backup from a zip file."""
from hermes_cli.backup import run_import
run_import(args)
def cmd_version(args):
"""Show version."""
print(f"Hermes Agent v{__version__} ({__release_date__})")
@@ -3905,6 +3939,26 @@ def cmd_update(args):
print()
print("✓ Update complete!")
# Write exit code *before* the gateway restart attempt.
# When running as ``hermes update --gateway`` (spawned by the gateway's
# /update command), this process lives inside the gateway's systemd
# cgroup. ``systemctl restart hermes-gateway`` kills everything in the
# cgroup (KillMode=mixed → SIGKILL to remaining processes), including
# us and the wrapping bash shell. The shell never reaches its
# ``printf $status > .update_exit_code`` epilogue, so the exit-code
# marker file is never created. The new gateway's update watcher then
# polls for 30 minutes and sends a spurious timeout message.
#
# Writing the marker here — after git pull + pip install succeed but
# before we attempt the restart — ensures the new gateway sees it
# regardless of how we die.
if gateway_mode:
_exit_code_path = get_hermes_home() / ".update_exit_code"
try:
_exit_code_path.write_text("0")
except OSError:
pass
# Auto-restart ALL gateways after update.
# The code update (git pull) is shared across all profiles, so every
# running gateway needs restarting to pick up the new code.
@@ -4904,7 +4958,43 @@ For more help on a command:
help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set"
)
dump_parser.set_defaults(func=cmd_dump)
# =========================================================================
# backup command
# =========================================================================
backup_parser = subparsers.add_parser(
"backup",
help="Back up Hermes home directory to a zip file",
description="Create a zip archive of your entire Hermes configuration, "
"skills, sessions, and data (excludes the hermes-agent codebase)"
)
backup_parser.add_argument(
"-o", "--output",
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)"
)
backup_parser.set_defaults(func=cmd_backup)
# =========================================================================
# import command
# =========================================================================
import_parser = subparsers.add_parser(
"import",
help="Restore a Hermes backup from a zip file",
description="Extract a previously created Hermes backup into your "
"Hermes home directory, restoring configuration, skills, "
"sessions, and data"
)
import_parser.add_argument(
"zipfile",
help="Path to the backup zip file"
)
import_parser.add_argument(
"--force", "-f",
action="store_true",
help="Overwrite existing files without confirmation"
)
import_parser.set_defaults(func=cmd_import)
# =========================================================================
# config command
# =========================================================================
+37 -2
View File
@@ -839,8 +839,11 @@ def list_authenticated_providers(
if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars):
has_creds = True
break
if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
# These use auth stores, not env vars — check for auth.json entries
# Check auth store and credential pool for non-env-var credentials.
# This applies to OAuth providers AND api_key providers that also
# support OAuth (e.g. anthropic supports both API key and Claude Code
# OAuth via external credential files).
if not has_creds:
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
@@ -853,6 +856,38 @@ def list_authenticated_providers(
has_creds = True
except Exception as exc:
logger.debug("Auth store check failed for %s: %s", pid, exc)
# Fallback: check the credential pool with full auto-seeding.
# This catches credentials that exist in external stores (e.g.
# Codex CLI ~/.codex/auth.json) which _seed_from_singletons()
# imports on demand but aren't in the raw auth.json yet.
if not has_creds:
try:
from agent.credential_pool import load_pool
pool = load_pool(hermes_slug)
if pool.has_credentials():
has_creds = True
except Exception as exc:
logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc)
# Fallback: check external credential files directly.
# The credential pool gates anthropic behind
# is_provider_explicitly_configured() to prevent auxiliary tasks
# from silently consuming Claude Code tokens (PR #4210).
# But the /model picker is discovery-oriented — we WANT to show
# providers the user can switch to, even if they aren't currently
# configured.
if not has_creds and hermes_slug == "anthropic":
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
hermes_creds = read_hermes_oauth_credentials()
cc_creds = read_claude_code_credentials()
if (hermes_creds and hermes_creds.get("accessToken")) or \
(cc_creds and cc_creds.get("accessToken")):
has_creds = True
except Exception as exc:
logger.debug("Anthropic external creds check failed: %s", exc)
if not has_creds:
continue
+14
View File
@@ -546,6 +546,20 @@ _PROVIDER_ALIASES = {
}
def get_default_model_for_provider(provider: str) -> str:
"""Return the default model for a provider, or empty string if unknown.
Uses the first entry in _PROVIDER_MODELS as the default. This is the
model a user would be offered first in the ``hermes model`` picker.
Used as a fallback when the user has configured a provider but never
selected a model (e.g. ``hermes auth add openai-codex`` without
``hermes model``).
"""
models = _PROVIDER_MODELS.get(provider, [])
return models[0] if models else ""
def _openrouter_model_is_free(pricing: Any) -> bool:
"""Return True when both prompt and completion pricing are zero."""
if not isinstance(pricing, dict):
+22 -10
View File
@@ -2232,6 +2232,7 @@ def setup_gateway(config: dict):
from hermes_cli.gateway import (
_is_service_installed,
_is_service_running,
supports_systemd_services,
has_conflicting_systemd_units,
install_linux_gateway_from_setup,
print_systemd_scope_conflict_warning,
@@ -2244,16 +2245,18 @@ def setup_gateway(config: dict):
service_installed = _is_service_installed()
service_running = _is_service_running()
supports_systemd = supports_systemd_services()
supports_service_manager = supports_systemd or _is_macos
print()
if _is_linux and has_conflicting_systemd_units():
if supports_systemd and has_conflicting_systemd_units():
print_systemd_scope_conflict_warning()
print()
if service_running:
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
try:
if _is_linux:
if supports_systemd:
systemd_restart()
elif _is_macos:
launchd_restart()
@@ -2262,14 +2265,14 @@ def setup_gateway(config: dict):
elif service_installed:
if prompt_yes_no(" Start the gateway service?", True):
try:
if _is_linux:
if supports_systemd:
systemd_start()
elif _is_macos:
launchd_start()
except Exception as e:
print_error(f" Start failed: {e}")
elif _is_linux or _is_macos:
svc_name = "systemd" if _is_linux else "launchd"
elif supports_service_manager:
svc_name = "systemd" if supports_systemd else "launchd"
if prompt_yes_no(
f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)",
True,
@@ -2277,7 +2280,7 @@ def setup_gateway(config: dict):
try:
installed_scope = None
did_install = False
if _is_linux:
if supports_systemd:
installed_scope, did_install = install_linux_gateway_from_setup(force=False)
else:
launchd_install(force=False)
@@ -2285,7 +2288,7 @@ def setup_gateway(config: dict):
print()
if did_install and prompt_yes_no(" Start the service now?", True):
try:
if _is_linux:
if supports_systemd:
systemd_start(system=installed_scope == "system")
elif _is_macos:
launchd_start()
@@ -2296,12 +2299,21 @@ def setup_gateway(config: dict):
print_info(" You can try manually: hermes gateway install")
else:
print_info(" You can install later: hermes gateway install")
if _is_linux:
if supports_systemd:
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
print_info(" Or run in foreground: hermes gateway")
else:
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway # Run in foreground")
from hermes_constants import is_container
if is_container():
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway run # Run as container main process")
print_info("")
print_info("For automatic restarts, use a Docker restart policy:")
print_info(" docker run --restart unless-stopped ...")
print_info(" docker restart <container> # Manual restart")
else:
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway # Run in foreground")
print_info("" * 50)
+29 -17
View File
@@ -346,23 +346,35 @@ def show_status(args):
print(" Note: Android may stop background jobs when Termux is suspended")
elif sys.platform.startswith('linux'):
try:
from hermes_cli.gateway import get_service_name
_gw_svc = get_service_name()
except Exception:
_gw_svc = "hermes-gateway"
try:
result = subprocess.run(
["systemctl", "--user", "is-active", _gw_svc],
capture_output=True,
text=True,
timeout=5
)
is_active = result.stdout.strip() == "active"
except (FileNotFoundError, subprocess.TimeoutExpired):
is_active = False
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
print(" Manager: systemd (user)")
from hermes_constants import is_container
if is_container():
# Docker/Podman: no systemd — check for running gateway processes
try:
from hermes_cli.gateway import find_gateway_pids
gateway_pids = find_gateway_pids()
is_active = len(gateway_pids) > 0
except Exception:
is_active = False
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
print(" Manager: docker (foreground)")
else:
try:
from hermes_cli.gateway import get_service_name
_gw_svc = get_service_name()
except Exception:
_gw_svc = "hermes-gateway"
try:
result = subprocess.run(
["systemctl", "--user", "is-active", _gw_svc],
capture_output=True,
text=True,
timeout=5
)
is_active = result.stdout.strip() == "active"
except (FileNotFoundError, subprocess.TimeoutExpired):
is_active = False
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
print(" Manager: systemd (user)")
elif sys.platform == 'darwin':
from hermes_cli.gateway import get_launchd_label
+351
View File
@@ -0,0 +1,351 @@
"""Random tips shown at CLI session start to help users discover features."""
import random
from typing import Optional
# ---------------------------------------------------------------------------
# Tip corpus — one-liners covering slash commands, CLI flags, config,
# keybindings, tools, gateway, skills, profiles, and workflow tricks.
# ---------------------------------------------------------------------------
TIPS = [
# --- Slash Commands ---
"/btw <question> asks a quick side question without tools or history — great for clarifications.",
"/background <prompt> runs a task in a separate session while your current one stays free.",
"/branch forks the current session so you can explore a different direction without losing progress.",
"/compress manually compresses conversation context when things get long.",
"/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.",
"/rollback diff 2 previews what changed since checkpoint 2 without restoring anything.",
"/rollback 2 src/file.py restores a single file from a specific checkpoint.",
"/title \"my project\" names your session — resume it later with /resume or hermes -c.",
"/resume picks up where you left off in a previously named session.",
"/queue <prompt> queues a message for the next turn without interrupting the current one.",
"/undo removes the last user/assistant exchange from the conversation.",
"/retry resends your last message — useful when the agent's response wasn't quite right.",
"/verbose cycles tool progress display: off → new → all → verbose.",
"/reasoning high increases the model's thinking depth. /reasoning show displays the reasoning.",
"/fast toggles priority processing for faster API responses (provider-dependent).",
"/yolo skips all dangerous command approval prompts for the rest of the session.",
"/model lets you switch models mid-session — try /model sonnet or /model gpt-5.",
"/model --global changes your default model permanently.",
"/personality pirate sets a fun personality — 14 built-in options from kawaii to shakespeare.",
"/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.",
"/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.",
"/tools disable browser temporarily removes browser tools for the current session.",
"/browser connect attaches browser tools to your running Chrome instance via CDP.",
"/plugins lists installed plugins and their status.",
"/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.",
"/reload-mcp hot-reloads MCP server configuration without restarting.",
"/usage shows token usage, cost breakdown, and session duration.",
"/insights shows usage analytics for the last 30 days.",
"/paste checks your clipboard for an image and attaches it to your next message.",
"/profile shows which profile is active and its home directory.",
"/config shows your current configuration at a glance.",
"/stop kills all running background processes spawned by the agent.",
# --- @ Context References ---
"@file:path/to/file.py injects file contents directly into your message.",
"@file:main.py:10-50 injects only lines 10-50 of a file.",
"@folder:src/ injects a directory tree listing.",
"@diff injects your unstaged git changes into the message.",
"@staged injects your staged git changes (git diff --staged).",
"@git:5 injects the last 5 commits with full patches.",
"@url:https://example.com fetches and injects a web page's content.",
"Typing @ triggers filesystem path completion — navigate to any file interactively.",
"Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"",
# --- Keybindings ---
"Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.",
"Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.",
"Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.",
"Tab accepts auto-suggestion ghost text or autocompletes slash commands.",
"Type a new message while the agent is working to interrupt and redirect it.",
"Alt+V pastes an image from your clipboard into the conversation.",
"Pasting 5+ lines auto-saves to a file and inserts a compact reference instead.",
# --- CLI Flags ---
"hermes -c resumes your most recent CLI session. hermes -c \"project name\" resumes by title.",
"hermes -w creates an isolated git worktree — perfect for parallel agent workflows.",
"hermes -w -q \"Fix issue #42\" combines worktree isolation with a one-shot query.",
"hermes chat -t web,terminal enables only specific toolsets for a focused session.",
"hermes chat -s github-pr-workflow preloads a skill at launch.",
"hermes chat -q \"query\" runs a single non-interactive query and exits.",
"hermes chat --max-turns 200 overrides the default 90-iteration limit per turn.",
"hermes chat --checkpoints enables filesystem snapshots before every destructive file change.",
"hermes --yolo bypasses all dangerous command approval prompts for the entire session.",
"hermes chat --source telegram tags the session for filtering in hermes sessions list.",
"hermes -p work chat runs under a specific profile without changing your default.",
# --- CLI Subcommands ---
"hermes doctor --fix diagnoses and auto-repairs config and dependency issues.",
"hermes dump outputs a compact setup summary — great for bug reports.",
"hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.",
"hermes config edit opens config.yaml in your default editor.",
"hermes config check scans for missing or stale configuration options.",
"hermes sessions browse opens an interactive session picker with search.",
"hermes sessions stats shows session counts by platform and database size.",
"hermes sessions prune --older-than 30 cleans up old sessions.",
"hermes skills search react --source skills-sh searches the skills.sh public directory.",
"hermes skills check scans installed hub skills for upstream updates.",
"hermes skills tap add myorg/skills-repo adds a custom GitHub skill source.",
"hermes skills snapshot export setup.json exports your skill configuration for backup or sharing.",
"hermes mcp add github --command npx adds MCP servers from the command line.",
"hermes mcp serve runs Hermes itself as an MCP server for other agents.",
"hermes auth add lets you add multiple API keys for credential pool rotation.",
"hermes completion bash >> ~/.bashrc enables tab completion for all commands and profiles.",
"hermes logs -f follows agent.log in real time. --level WARNING --since 1h filters output.",
"hermes backup creates a zip backup of your entire Hermes home directory.",
"hermes profile create coder creates an isolated profile that becomes its own command.",
"hermes profile create work --clone copies your current config and keys to a new profile.",
"hermes update syncs new bundled skills to ALL profiles automatically.",
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
# --- Configuration ---
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
"Set display.streaming: true to see tokens appear in real time as the model generates.",
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
"Set display.compact: true to reduce whitespace in output for denser information.",
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",
"Set file_read_max_chars: 200000 to increase the max content per read_file call.",
"Set approvals.mode: smart to let an LLM auto-approve safe commands and auto-deny dangerous ones.",
"Set fallback_model in config.yaml to automatically fail over to a backup provider.",
"Set privacy.redact_pii: true to hash user IDs and phone numbers before sending to the LLM.",
"Set browser.record_sessions: true to auto-record browser sessions as WebM videos.",
"Set worktree: true in config.yaml to always create a git worktree (same as hermes -w).",
"Set security.website_blocklist.enabled: true to block specific domains from web tools.",
"Set cron.wrap_response: false to deliver raw agent output without the cron header/footer.",
"HERMES_TIMEZONE overrides the server timezone with any IANA timezone string.",
"Environment variable substitution works in config.yaml: use ${VAR_NAME} syntax.",
"Quick commands in config.yaml run shell commands instantly with zero token usage.",
"Custom personalities can be defined in config.yaml under agent.personalities.",
"provider_routing controls OpenRouter provider sorting, whitelisting, and blacklisting.",
# --- Tools & Capabilities ---
"execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.",
"delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.",
"web_extract works on PDF URLs — pass any PDF link and it converts to markdown.",
"search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.",
"patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.",
"patch supports V4A format for bulk multi-file edits in a single call.",
"read_file suggests similar filenames when a file isn't found.",
"read_file auto-deduplicates — re-reading an unchanged file returns a lightweight stub.",
"browser_vision takes a screenshot and analyzes it with AI — works for CAPTCHAs and visual content.",
"browser_console can evaluate JavaScript expressions in the page context.",
"image_generate creates images with FLUX 2 Pro and automatic 2x upscaling.",
"text_to_speech converts text to audio — plays as voice bubbles on Telegram.",
"send_message can reach any connected messaging platform from within a session.",
"The todo tool helps the agent track complex multi-step tasks during a session.",
"session_search performs full-text search across ALL past conversations.",
"The agent automatically saves preferences, corrections, and environment facts to memory.",
"mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.",
"Terminal commands support background mode with notify_on_complete for long-running tasks.",
"Terminal background processes support watch_patterns to alert on specific output lines.",
"The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.",
# --- Profiles ---
"Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.",
"Profile names become shell commands — 'hermes profile create coder' creates the 'coder' command.",
"hermes profile export coder -o backup.tar.gz creates a portable profile archive.",
"If two profiles accidentally share a bot token, the second gateway is blocked with a clear error.",
# --- Sessions ---
"Sessions auto-generate descriptive titles after the first exchange — no manual naming needed.",
"Session titles support lineage: \"my project\"\"my project #2\"\"my project #3\".",
"When exiting, Hermes prints a resume command with session ID and stats.",
"hermes sessions export backup.jsonl exports all sessions for backup or analysis.",
"hermes -r SESSION_ID resumes any specific past session by its ID.",
# --- Memory ---
"Memory is a frozen snapshot — changes appear in the system prompt only at next session start.",
"Memory entries are automatically scanned for prompt injection and exfiltration patterns.",
"The agent has two memory stores: personal notes (~2200 chars) and user profile (~1375 chars).",
"Corrections you give the agent (\"no, do it this way\") are often auto-saved to memory.",
# --- Skills ---
"Over 80 bundled skills covering github, creative, mlops, productivity, research, and more.",
"Every installed skill automatically becomes a slash command — type / to see them all.",
"hermes skills install official/security/1password installs optional skills from the repo.",
"Skills can restrict to specific OS platforms — some only load on macOS or Linux.",
"skills.external_dirs in config.yaml lets you load skills from custom directories.",
"The agent can create its own skills as procedural memory using skill_manage.",
"The plan skill saves markdown plans under .hermes/plans/ in the active workspace.",
# --- Cron & Scheduling ---
"Cron jobs can attach skills: hermes cron add --skill blogwatcher \"Check for new posts\".",
"Cron delivery targets include telegram, discord, slack, email, sms, and 12+ more platforms.",
"If a cron response starts with [SILENT], delivery is suppressed — useful for monitoring-only jobs.",
"Cron supports relative delays (30m), intervals (every 2h), cron expressions, and ISO timestamps.",
"Cron jobs run in completely fresh agent sessions — prompts must be self-contained.",
# --- Voice ---
"Voice mode works with zero API keys if faster-whisper is installed (free local speech-to-text).",
"Five TTS providers available: Edge TTS (free), ElevenLabs, OpenAI, NeuTTS (free local), MiniMax.",
"/voice on enables voice mode in the CLI. Ctrl+B toggles push-to-talk recording.",
"Streaming TTS plays sentences as they generate — you don't wait for the full response.",
"Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.",
# --- Gateway & Messaging ---
"Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.",
"hermes gateway install sets it up as a system service that starts on boot.",
"DingTalk uses Stream Mode — no webhooks or public URL needed.",
"BlueBubbles brings iMessage to Hermes via a local macOS server.",
"Webhook routes support HMAC validation, rate limiting, and event filtering.",
"The API server exposes an OpenAI-compatible endpoint compatible with Open WebUI and LibreChat.",
"Discord voice channel mode: the bot joins VC, transcribes speech, and talks back.",
"group_sessions_per_user: true gives each person their own session in group chats.",
"/sethome marks a chat as the home channel for cron job deliveries.",
"The gateway supports inactivity-based timeouts — active agents can run indefinitely.",
# --- Security ---
"Dangerous command approval has 4 tiers: once, session, always (permanent allowlist), deny.",
"Smart approval mode uses an LLM to auto-approve safe commands and flag dangerous ones.",
"SSRF protection blocks private networks, loopback, link-local, and cloud metadata addresses.",
"Tirith pre-exec scanning detects homograph URL spoofing and pipe-to-interpreter patterns.",
"MCP subprocesses receive a filtered environment — only safe system vars pass through.",
"Context files (.hermes.md, AGENTS.md) are security-scanned for prompt injection before loading.",
"command_allowlist in config.yaml permanently approves specific shell command patterns.",
# --- Context & Compression ---
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
"The status bar turns yellow, then orange, then red as context fills up.",
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
"Context files are capped at 20,000 characters with smart head/tail truncation.",
# --- Browser ---
"Five browser providers: local Chromium, Browserbase, Browser Use, Camofox, and Firecrawl.",
"Camofox is an anti-detection browser — Firefox fork with C++ fingerprint spoofing.",
"browser_navigate returns a page snapshot automatically — no need to call browser_snapshot after.",
"browser_vision with annotate=true overlays numbered labels on interactive elements.",
# --- MCP ---
"MCP servers are configured in config.yaml — both stdio and HTTP transports supported.",
"Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.",
"MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.",
"MCP OAuth support: auth: oauth enables browser-based authorization with PKCE.",
# --- Checkpoints & Rollback ---
"Checkpoints have zero overhead when no files are modified — enabled by default.",
"A pre-rollback snapshot is saved automatically so you can undo the undo.",
"/rollback also undoes the conversation turn, so the agent doesn't remember rolled-back changes.",
"Checkpoints use shadow repos in ~/.hermes/checkpoints/ — your project's .git is never touched.",
# --- Batch & Data ---
"batch_runner.py processes hundreds of prompts in parallel for training data generation.",
"hermes chat -Q enables quiet mode for programmatic use — suppresses banner and spinner.",
"Trajectory saving (--save-trajectories) captures full tool-use traces for model training.",
# --- Plugins ---
"Three plugin types: general (tools/hooks), memory providers, and context engines.",
"hermes plugins install owner/repo installs plugins directly from GitHub.",
"8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.",
"Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.",
# --- Miscellaneous ---
"Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.",
"The agent auto-generates session titles in a background thread — zero latency impact.",
"Smart model routing can auto-route simple queries to a cheaper model.",
"Slash commands support prefix matching: /h resolves to /help, /mod to /model.",
"Dragging a file path into the terminal auto-attaches images or sends as context.",
".worktreeinclude in your repo root lists gitignored files to copy into worktrees.",
"hermes acp runs Hermes as an ACP server for VS Code, Zed, and JetBrains integration.",
"Custom providers: save named endpoints in config.yaml under custom_providers.",
"HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.",
"credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.",
"hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.",
"The API server supports both Chat Completions and Responses API with server-side state.",
"tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.",
"hermes status --deep runs deeper diagnostic checks across all components.",
# --- Hidden Gems & Power-User Tricks ---
"BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.",
"Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.",
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
"network.force_ipv4: true in config.yaml fixes hangs on servers with broken IPv6 — monkey-patches socket.",
"The terminal tool annotates common exit codes: grep returning 1 = 'No matches found (not an error)'.",
"Failed foreground terminal commands auto-retry up to 3 times with exponential backoff (2s, 4s, 8s).",
"Bare sudo commands are auto-rewritten to pipe SUDO_PASSWORD from .env — no interactive prompt needed.",
"execute_code has built-in helpers: json_parse() for tolerant parsing, shell_quote(), and retry() with backoff.",
"execute_code's 7 sandbox tools (web_search, terminal, read/write/search/patch) use RPC — never enter context.",
"Reading the same file region 3+ times triggers a warning. At 4+, it's hard-blocked to prevent loops.",
"write_file and patch detect if a file was externally modified since the last read and warn about staleness.",
"V4A patch format supports Add File, Delete File, and Move File directives — not just Update.",
"MCP servers can request LLM completions back via sampling — the agent becomes a tool for the server.",
"MCP servers send notifications/tools/list_changed to trigger automatic tool re-registration without restart.",
"delegate_task with acp_command: 'claude' spawns Claude Code as a child agent from any platform.",
"Delegation has a heartbeat thread — child activity propagates to the parent, preventing gateway timeouts.",
"When a provider returns HTTP 402 (payment required), the auxiliary client auto-falls back to the next one.",
"agent.tool_use_enforcement steers models that describe actions instead of calling tools — auto for GPT/Codex.",
"agent.restart_drain_timeout (default 60s) lets running agents finish before a gateway restart takes effect.",
"The gateway caches AIAgent instances per session — destroying this cache breaks Anthropic prompt caching.",
"Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.",
"The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.",
"Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.",
"Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.",
"HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.",
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
"Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.",
"BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.",
"BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).",
"The CLI auto-switches to compact mode in terminals narrower than 80 columns.",
"Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
"Per-task delegation model: delegation.model and delegation.provider in config route subagents to cheaper models.",
"delegation.reasoning_effort independently controls thinking depth for subagents.",
"display.platforms in config.yaml allows per-platform display overrides: {telegram: {tool_progress: all}}.",
"human_delay.mode in config simulates human typing speed — configurable min_ms/max_ms range.",
"Config version migrations run automatically on load — new config keys appear without manual intervention.",
"GPT and Codex models get special system prompt guidance for tool discipline and mandatory tool use.",
"Gemini models get tailored directives for absolute paths, parallel tool calls, and non-interactive commands.",
"context.engine in config.yaml can be set to a plugin name for alternative context management strategies.",
"Browser pages over 8000 tokens are auto-summarized by the auxiliary LLM before returning to the agent.",
"The compressor does a cheap pre-pass: tool outputs over 200 chars are replaced with placeholders before the LLM runs.",
"When compression fails, further attempts are paused for 10 minutes to avoid API hammering.",
"Long dangerous commands (>70 chars) get a 'view' option in the approval prompt to see the full text first.",
"Audio level visualization shows ▁▂▃▄▅▆▇ bars during voice recording based on microphone RMS levels.",
"Profile names cannot collide with existing PATH binaries — 'hermes profile create ls' would be rejected.",
"hermes profile create backup --clone-all copies everything (config, keys, SOUL.md, memories, skills, sessions).",
"The voice record key is configurable via voice.record_key in config.yaml — not just Ctrl+B.",
".cursorrules and .cursor/rules/*.mdc files are auto-detected and loaded as project context.",
"Context files support 10+ prompt injection patterns — invisible Unicode, 'ignore instructions', exfil attempts.",
"GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
"Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
"The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
"Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
"hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
"File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
"Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
"In interrupt mode, slash commands typed during agent execution bypass interrupt logic and run immediately.",
"HERMES_DEV=1 bypasses container mode detection for local development.",
"Each MCP server gets its own toolset (mcp-servername) that can be toggled independently via hermes tools.",
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
]
def get_random_tip(exclude_recent: int = 0) -> str:
"""Return a random tip string.
Args:
exclude_recent: not used currently; reserved for future
deduplication across sessions.
"""
return random.choice(TIPS)
def get_tip_count() -> int:
"""Return the total number of tips available."""
return len(TIPS)
+76
View File
@@ -189,6 +189,37 @@ def is_wsl() -> bool:
return _wsl_detected
_container_detected: bool | None = None
def is_container() -> bool:
"""Return True when running inside a Docker/Podman container.
Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
and ``/proc/1/cgroup`` for container runtime markers. Result is
cached for the process lifetime. Import-safe no heavy deps.
"""
global _container_detected
if _container_detected is not None:
return _container_detected
if os.path.exists("/.dockerenv"):
_container_detected = True
return True
if os.path.exists("/run/.containerenv"):
_container_detected = True
return True
try:
with open("/proc/1/cgroup", "r") as f:
cgroup = f.read()
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
_container_detected = True
return True
except OSError:
pass
_container_detected = False
return False
# ─── Well-Known Paths ─────────────────────────────────────────────────────────
@@ -216,6 +247,51 @@ def get_env_path() -> Path:
return get_hermes_home() / ".env"
# ─── Network Preferences ─────────────────────────────────────────────────────
def apply_ipv4_preference(force: bool = False) -> None:
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections.
On servers with broken or unreachable IPv6, Python tries AAAA records
first and hangs for the full TCP timeout before falling back to IPv4.
This affects httpx, requests, urllib, the OpenAI SDK everything that
uses ``socket.getaddrinfo``.
When *force* is True, patches ``getaddrinfo`` so that calls with
``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead,
skipping IPv6 entirely. If no A record exists, falls back to the
original unfiltered resolution so pure-IPv6 hosts still work.
Safe to call multiple times only patches once.
Set ``network.force_ipv4: true`` in ``config.yaml`` to enable.
"""
if not force:
return
import socket
# Guard against double-patching
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
return
_original_getaddrinfo = socket.getaddrinfo
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
if family == 0: # AF_UNSPEC — caller didn't request a specific family
try:
return _original_getaddrinfo(
host, port, socket.AF_INET, type, proto, flags
)
except socket.gaierror:
# No A record — fall back to full resolution (pure-IPv6 hosts)
return _original_getaddrinfo(host, port, family, type, proto, flags)
return _original_getaddrinfo(host, port, family, type, proto, flags)
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
@@ -376,6 +376,24 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:
return dest
# ── Brand rewriting ─────────────────────────────────────────
# Replace OpenClaw brand names with Hermes in migrated text so that
# memory entries, user profiles, SOUL.md, and workspace instructions
# read as self-referential to the new agent identity.
_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
(re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),
(re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),
(re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'),
]
def rebrand_text(text: str) -> str:
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes."""
for pattern, replacement in _REBRAND_PATTERNS:
text = pattern.sub(replacement, text)
return text
def parse_existing_memory_entries(path: Path) -> List[str]:
if not path.exists():
return []
@@ -782,12 +800,13 @@ class Migrator:
path.write_text("\n".join(entries) + "\n", encoding="utf-8")
return path
def copy_file(self, source: Path, destination: Path, kind: str) -> None:
def copy_file(self, source: Path, destination: Path, kind: str,
transform: Optional[Any] = None) -> None:
if not source or not source.exists():
return
if destination.exists():
if sha256_file(source) == sha256_file(destination):
if not transform and sha256_file(source) == sha256_file(destination):
self.record(kind, source, destination, "skipped", "Target already matches source")
return
if not self.overwrite:
@@ -797,7 +816,13 @@ class Migrator:
if self.execute:
backup_path = self.maybe_backup(destination)
ensure_parent(destination)
shutil.copy2(source, destination)
if transform:
content = read_text(source)
content = transform(content)
destination.write_text(content, encoding="utf-8")
shutil.copystat(source, destination)
else:
shutil.copy2(source, destination)
self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None)
else:
self.record(kind, source, destination, "migrated", "Would copy")
@@ -807,7 +832,7 @@ class Migrator:
if not source:
self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found")
return
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text)
def migrate_workspace_agents(self) -> None:
source = self.source_candidate(
@@ -821,7 +846,7 @@ class Migrator:
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
return
destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME
self.copy_file(source, destination, kind="workspace-agents")
self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text)
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
if not source or not source.exists():
@@ -832,6 +857,7 @@ class Migrator:
if not incoming:
self.record(kind, source, destination, "skipped", "No importable entries found")
return
incoming = [rebrand_text(entry) for entry in incoming]
existing = parse_existing_memory_entries(destination)
merged, stats, overflowed = merge_entries(existing, incoming, limit)
@@ -927,7 +953,7 @@ class Migrator:
def load_openclaw_config(self) -> Dict[str, Any]:
# Check current name and legacy config filenames
for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
for name in ("openclaw.json", "clawdbot.json", "moltbot.json"):
config_path = self.source_root / name
if config_path.exists():
try:
@@ -997,7 +1023,17 @@ class Migrator:
.get("workspace")
)
if isinstance(workspace, str) and workspace.strip():
additions["MESSAGING_CWD"] = workspace.strip()
ws_path = workspace.strip()
# Skip if the workspace points inside the OpenClaw source directory —
# that path will be stale after migration and would cause the Hermes
# gateway to use the old OpenClaw workspace as its cwd, picking up
# OpenClaw's AGENTS.md, MEMORY.md, etc.
try:
inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve())
except (ValueError, OSError):
inside_source = False
if not inside_source:
additions["MESSAGING_CWD"] = ws_path
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
if allowlist_path.exists():
@@ -1543,6 +1579,7 @@ class Migrator:
if not all_incoming:
self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files")
return
all_incoming = [rebrand_text(entry) for entry in all_incoming]
existing = parse_existing_memory_entries(destination)
merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit)
+1 -1
View File
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
cron = ["croniter>=6.0.0,<7"]
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4"]
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
cli = ["simple-term-menu>=1.0,<2"]
tts-premium = ["elevenlabs>=1.0,<2"]
voice = [
+32 -5
View File
@@ -94,7 +94,7 @@ from agent.model_metadata import (
from agent.context_compressor import ContextCompressor
from agent.subdirectory_hints import SubdirectoryHintTracker
from agent.prompt_caching import apply_anthropic_cache_control
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from agent.display import (
KawaiiSpinner, build_tool_preview as _build_tool_preview,
@@ -1307,6 +1307,7 @@ class AIAgent:
api_key=getattr(self, "api_key", ""),
config_context_length=_config_context_length,
provider=self.provider,
api_mode=self.api_mode,
)
self.compression_enabled = compression_enabled
@@ -1563,6 +1564,7 @@ class AIAgent:
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
api_mode=self.api_mode,
)
# ── Invalidate cached system prompt so it rebuilds next turn ──
@@ -1696,6 +1698,16 @@ class AIAgent:
except Exception:
logger.debug("status_callback error in _emit_status", exc_info=True)
def _current_main_runtime(self) -> Dict[str, str]:
"""Return the live main runtime for session-scoped auxiliary routing."""
return {
"model": getattr(self, "model", "") or "",
"provider": getattr(self, "provider", "") or "",
"base_url": getattr(self, "base_url", "") or "",
"api_key": getattr(self, "api_key", "") or "",
"api_mode": getattr(self, "api_mode", "") or "",
}
def _check_compression_model_feasibility(self) -> None:
"""Warn at session start if the auxiliary compression model's context
window is smaller than the main model's compression threshold.
@@ -1716,7 +1728,10 @@ class AIAgent:
from agent.auxiliary_client import get_text_auxiliary_client
from agent.model_metadata import get_model_context_length
client, aux_model = get_text_auxiliary_client("compression")
client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=self._current_main_runtime(),
)
if client is None or not aux_model:
msg = (
"⚠ No auxiliary LLM provider configured — context "
@@ -3178,6 +3193,12 @@ class AIAgent:
f"not on any model name returned by the API."
)
# Environment hints (WSL, Termux, etc.) — tell the agent about the
# execution environment so it can translate paths and adapt behavior.
_env_hints = build_environment_hints()
if _env_hints:
prompt_parts.append(_env_hints)
platform_key = (self.platform or "").lower().strip()
if platform_key in PLATFORM_HINTS:
prompt_parts.append(PLATFORM_HINTS[platform_key])
@@ -6548,17 +6569,23 @@ class AIAgent:
if messages and messages[-1].get("_flush_sentinel") == _sentinel:
messages.pop()
def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default") -> tuple:
def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default", focus_topic: str = None) -> tuple:
"""Compress conversation context and split the session in SQLite.
Args:
focus_topic: Optional focus string for guided compression the
summariser will prioritise preserving information related to
this topic. Inspired by Claude Code's ``/compact <focus>``.
Returns:
(compressed_messages, new_system_prompt) tuple
"""
_pre_msg_count = len(messages)
logger.info(
"context compression started: session=%s messages=%d tokens=~%s model=%s",
"context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r",
self.session_id or "none", _pre_msg_count,
f"{approx_tokens:,}" if approx_tokens else "unknown", self.model,
focus_topic,
)
# Pre-compression memory flush: let the model save memories before they're lost
self.flush_memories(messages, min_turns=0)
@@ -6570,7 +6597,7 @@ class AIAgent:
except Exception:
pass
compressed = self.context_compressor.compress(messages, current_tokens=approx_tokens)
compressed = self.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic)
todo_snapshot = self._todo_store.format_for_injection()
if todo_snapshot:
+374
View File
@@ -0,0 +1,374 @@
# Python Module Taste Guide
_Opinionated notes on structuring Python projects where many people (and agents) contribute. Not a style guide — a taste document._
---
## 1. The file is the unit of understanding
Every `.py` file should be explainable in one sentence. If you can't say "this file handles X" without using the word "and", split it.
```
# Good: one sentence each
backends/docker.py → "Docker execution backend"
backends/ssh.py → "SSH execution backend"
agent/planner.py → "Step planning and decomposition"
agent/executor.py → "Tool dispatch and result collection"
# Bad: needs "and"
agent/core.py → "Planning and execution and tool dispatch and error handling"
utils/helpers.py → "Retry logic and string formatting and path resolution"
```
A 400-line file with one clear purpose is better than four 100-line files with fuzzy purposes.
---
## 2. Directory = namespace = concept boundary
A directory exists to group files that share a concept AND need to import each other. If the files don't need each other, they don't need a directory — they can be siblings.
```
# This directory earns its existence:
backends/
├── __init__.py # re-exports Backend, DockerBackend, etc.
├── base.py # Protocol/ABC
├── docker.py # imports base
├── ssh.py # imports base
└── local.py # imports base
# This directory shouldn't exist:
utils/
├── __init__.py
├── retry.py # used by backends
├── formatting.py # used by cli
└── paths.py # used by config
# These have nothing to do with each other. Just put them where they're used.
```
**Test:** if you delete the `__init__.py` and the directory, would each file work as a top-level module? If yes, the directory is probably just cosmetic grouping, not a real namespace.
---
## 3. Flat until it hurts (the two-level rule)
Start with at most two levels of nesting under `src/`. Add a third level only when a directory has 7+ files AND they cluster into obvious sub-groups.
```
# Good: two levels
src/hermes_agent/
├── backends/
├── agent/
├── tools/
├── config/
└── cli/
# Premature: three levels when you only have 2 files
src/hermes_agent/
└── backends/
└── docker/
├── __init__.py
├── container.py # only 80 lines
└── image.py # only 60 lines
# Just keep this as backends/docker.py until it's 300+ lines
```
Depth costs cognitive overhead. Every nested directory is a question: "do I look in `docker/` or `docker/container/`?" Flat trees answer questions faster.
---
## 4. `__init__.py` is your public API
Treat `__init__.py` as the **only** file external consumers should import from. Everything else is internal.
```python
# backends/__init__.py
from .base import Backend, ExecResult
from .docker import DockerBackend
from .local import LocalBackend
from .ssh import SSHBackend
__all__ = ["Backend", "ExecResult", "DockerBackend", "LocalBackend", "SSHBackend"]
```
This gives you freedom to refactor internals. You can split `docker.py` into `docker_container.py` + `docker_network.py` without changing any external imports — because everyone imports from `backends`, not from `backends.docker`.
**Rule:** if you see `from hermes_agent.backends.docker import DockerBackend` in the agent code, that's a smell. It should be `from hermes_agent.backends import DockerBackend`.
---
## 5. Dependency arrows flow one way
Draw the import graph. It should be a DAG with clear layers:
```
cli
agent
↓ ↘
tools backends
↓ ↓
config config
```
**Hard rules:**
- `config` imports nothing from the project (it's the leaf)
- `backends` never imports from `agent`
- `tools` never imports from `agent`
- `agent` imports from `tools` and `backends`
- `cli` imports from `agent` (and maybe `config`)
If you're tempted to create a circular import, you're missing an interface. Extract the shared type into `config` or a `types.py` at the appropriate level.
```python
# Bad: circular
# agent/executor.py imports backends.docker
# backends/docker.py imports agent.state ← circular!
# Fix: extract the shared type
# types.py (or config/types.py)
@dataclass
class AgentState:
...
# Now both agent and backends can import from types
```
---
## 6. One file owns each type
Every important class/dataclass/Protocol should live in exactly one file, and that file should be obvious from the type name.
```
BackendConfig → config/backends.py (or config.py if config is flat)
DockerBackend → backends/docker.py
AgentLoop → agent/loop.py
ToolRegistry → tools/registry.py
```
Anti-pattern: putting `BackendConfig` in `backends/base.py` because "it's related to backends." No — config lives in config. Backends _use_ config, they don't _define_ config. This keeps the dependency arrows clean.
---
## 7. Protocols over ABCs for external contracts
Use `typing.Protocol` when you want to define "what shape does this thing have" without forcing inheritance. Use ABCs when you want to share implementation.
```python
# Protocol: structural subtyping, no inheritance needed
# Good for: interfaces consumed by other packages
@runtime_checkable
class Backend(Protocol):
async def execute(self, cmd: str) -> ExecResult: ...
async def upload(self, local: Path, remote: str) -> None: ...
# ABC: nominal subtyping, shared implementation
# Good for: when backends share 50+ lines of common logic
class BaseBackend(ABC):
def __init__(self, config: BackendConfig):
self.config = config
self._setup_logging() # shared
@abstractmethod
async def execute(self, cmd: str) -> ExecResult: ...
def _setup_logging(self): # shared implementation
...
```
**Default to Protocol.** Reach for ABC only when you have real shared code, not just shared signatures.
---
## 8. Config is typed, loaded once, passed explicitly
```python
# config.py
from pydantic import BaseModel
class BackendConfig(BaseModel):
type: str = "local"
docker_image: str | None = None
ssh_host: str | None = None
timeout: float = 30.0
class AgentConfig(BaseModel):
model: str = "gpt-4o"
max_steps: int = 50
backend: BackendConfig = BackendConfig()
# Loading happens once, at the edge
def load_config(path: Path) -> AgentConfig:
raw = yaml.safe_load(path.read_text())
return AgentConfig(**raw)
```
**Rules:**
- Config classes import nothing from the project
- Config is loaded in `cli/` or `main()`, never inside library code
- No module-level globals like `CONFIG = load_config()`. Pass it through constructors.
- No `os.environ.get()` scattered through library code. Read env vars in config loading only.
---
## 9. The "where does new code go?" test
Before committing to a structure, simulate these scenarios:
| Scenario | Should be obvious where to add it |
| ---------------------------------------- | ------------------------------------------------------- |
| New execution backend (e.g., Kubernetes) | `backends/kubernetes.py` + register in `__init__.py` |
| New CLI subcommand | `cli/new_command.py` or a function in existing cli file |
| New tool for the agent | `tools/new_tool.py` + register in tool registry |
| New config option | Add field to existing config model in `config.py` |
| Bug fix in SSH execution | `backends/ssh.py`, nowhere else |
| New eval benchmark | `eval/new_benchmark.py` |
If any of these require touching 5+ files or the answer is "I'm not sure," the structure needs work.
---
## 10. Files that earn their existence
Every file in the project should pass one of these tests:
1. **It's the single home for a concept** (e.g., `docker.py` owns DockerBackend)
2. **It's a boundary** (e.g., `__init__.py` defines the public API)
3. **It's an entrypoint** (e.g., `__main__.py`, CLI commands)
4. **It's config/constants** (e.g., `config.py`, `defaults.py`)
Files that don't pass: `helpers.py`, `misc.py`, `common.py`, `base.py` (when it has no ABC/Protocol), `types.py` (when it has 2 types that belong in their respective modules).
---
## 11. Naming that communicates
**Files:** noun or noun_phrase, lowercase_snake. The name should tell you what's _in_ the file, not what it _does_.
```
# Good: tells you what's inside
registry.py # contains ToolRegistry
docker.py # contains DockerBackend
planner.py # contains Planner, PlanStep
# Bad: tells you what it does (vague)
run.py # run what?
process.py # process what?
handle.py # handle what?
```
**Directories:** plural nouns for collections, singular for a single concern.
```
backends/ # plural: collection of backend implementations
config/ # singular: one concern
tools/ # plural: collection of tools
agent/ # singular: one agent system
```
---
## 12. Tests: organize by confidence, not by source
```
tests/
├── unit/ # fast, isolated, mock everything external
│ ├── test_planner.py
│ └── test_config.py
├── integration/ # real backends, real I/O, but controlled
│ ├── test_docker_backend.py
│ └── test_ssh_backend.py
├── e2e/ # full agent runs, slow, CI-only
│ ├── test_ctf_solve.py
│ └── test_migration.py
└── fixtures/ # shared test data
├── sample_config.yaml
└── mock_responses/
```
Don't mirror `src/` 1:1. Test files group by _what you're verifying_, not which source file they exercise. `test_agent_can_recover_from_backend_failure.py` might touch `agent/`, `backends/`, and `config/` — and that's fine.
---
## 13. The import order tells a story
Within a file, imports should read top-down as: stdlib → third-party → project internals, with project internals going from "far away" to "nearby."
```python
# stdlib
import asyncio
from pathlib import Path
# third-party
from pydantic import BaseModel
# project: far away (config is a leaf, used everywhere)
from hermes_agent.config import BackendConfig
# project: nearby (same package)
from .base import Backend, ExecResult
```
This isn't just aesthetics — it makes dependency direction visible at a glance.
---
## 14. When to split a file
Split when ANY of these are true:
- File exceeds ~400 lines AND has 2+ distinct responsibilities
- Two people frequently have merge conflicts in the same file
- You find yourself adding `# --- Section: X ---` comments to navigate
- The file has internal classes/functions that another module wants to import
Do NOT split just because:
- The file is "long" (a 600-line file with one clear purpose is fine)
- You "might need to" someday
- A linter told you to
---
## 15. Module-level code is a liability
Every line that runs at import time is a line that can break `import hermes_agent`.
```python
# Bad: runs at import time
import docker
client = docker.from_env() # crashes if Docker isn't running
# Good: lazy, runs when needed
def get_docker_client():
import docker
return docker.from_env()
# Also good: runs in __init__, not at module level
class DockerBackend:
def __init__(self, config: BackendConfig):
import docker
self._client = docker.from_env()
```
**Rule:** module-level code should only be: imports, type definitions, constants, and function/class definitions. Never side effects.
---
## Reading list
These are worth reading not for rules but for _calibrating your taste_:
- **Hynek Schlawack — [Testing & Packaging](https://hynek.me/articles/testing-packaging/)**: Best single article on src layout and why it matters.
- **Python Packaging Guide — [src layout vs flat layout](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/)**: The official take.
- **Brandon Rhodes — [The Clean Architecture in Python](https://rhodesmill.org/brandon/talks/#clean-architecture-python)** (PyCon talk): Good for understanding dependency direction without going full enterprise.
- **Cosmicpython — [Architecture Patterns with Python](https://www.cosmicpython.com/)**: Free online book. Chapters 1-4 on repository pattern and dependency inversion are relevant; skip the CQRS/event-sourcing stuff unless you need it.
- **Hatch documentation**: Modern Python project management. Reading how Hatch structures things will passively teach you good layout conventions.
- **Any well-structured open source project**: `httpx`, `pydantic`, `ruff` (Rust but the Python wrapper layout is instructive), `textual`. Read their `src/` trees and `__init__.py` files.
---
_The goal is not elegance. The goal is that a new contributor — human or agent — can go from "I need to change X" to "I know which file to open" in under 10 seconds._
+139
View File
@@ -971,6 +971,74 @@ class TestTaskSpecificOverrides:
client, model = get_text_auxiliary_client("compression")
assert model == "google/gemini-3-flash-preview" # auto → OpenRouter
def test_resolve_auto_prefers_live_main_runtime_over_persisted_config(self, monkeypatch, tmp_path):
"""Session-only live model switches should override persisted config for auto routing."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "config.yaml").write_text(
"""model:
default: glm-5.1
provider: opencode-go
compression:
summary_provider: auto
"""
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
calls = []
def _fake_resolve(provider, model=None, *args, **kwargs):
calls.append((provider, model, kwargs))
return MagicMock(), model or "resolved-model"
with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve):
client, model = _resolve_auto(
main_runtime={
"provider": "openai-codex",
"model": "gpt-5.4",
"api_mode": "codex_responses",
}
)
assert client is not None
assert model == "gpt-5.4"
assert calls[0][0] == "openai-codex"
assert calls[0][1] == "gpt-5.4"
assert calls[0][2]["api_mode"] == "codex_responses"
def test_explicit_compression_pin_still_wins_over_live_main_runtime(self, monkeypatch, tmp_path):
"""Task-level compression config should beat a live session override."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "config.yaml").write_text(
"""auxiliary:
compression:
provider: openrouter
model: google/gemini-3-flash-preview
model:
default: glm-5.1
provider: opencode-go
"""
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with patch("agent.auxiliary_client.resolve_provider_client", return_value=(MagicMock(), "google/gemini-3-flash-preview")) as mock_resolve:
client, model = get_text_auxiliary_client(
"compression",
main_runtime={
"provider": "openai-codex",
"model": "gpt-5.4",
},
)
assert client is not None
assert model == "google/gemini-3-flash-preview"
assert mock_resolve.call_args.args[0] == "openrouter"
assert mock_resolve.call_args.kwargs["main_runtime"] == {
"provider": "openai-codex",
"model": "gpt-5.4",
}
def test_compression_summary_base_url_from_config(self, monkeypatch, tmp_path):
"""compression.summary_base_url should produce a custom-endpoint client."""
hermes_home = tmp_path / "hermes"
@@ -1560,3 +1628,74 @@ class TestStaleBaseUrlWarning:
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
"Warning should not fire a second time"
# ---------------------------------------------------------------------------
# Anthropic-compatible image block conversion
# ---------------------------------------------------------------------------
class TestAnthropicCompatImageConversion:
"""Tests for _is_anthropic_compat_endpoint and _convert_openai_images_to_anthropic."""
def test_known_providers_detected(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
assert _is_anthropic_compat_endpoint("minimax", "")
assert _is_anthropic_compat_endpoint("minimax-cn", "")
def test_openrouter_not_detected(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
assert not _is_anthropic_compat_endpoint("openrouter", "")
assert not _is_anthropic_compat_endpoint("anthropic", "")
def test_url_based_detection(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
assert _is_anthropic_compat_endpoint("custom", "https://api.minimax.io/anthropic")
assert _is_anthropic_compat_endpoint("custom", "https://example.com/anthropic/v1")
assert not _is_anthropic_compat_endpoint("custom", "https://api.openai.com/v1")
def test_base64_image_converted(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
messages = [{
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR="}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
img_block = result[0]["content"][1]
assert img_block["type"] == "image"
assert img_block["source"]["type"] == "base64"
assert img_block["source"]["media_type"] == "image/png"
assert img_block["source"]["data"] == "iVBOR="
def test_url_image_converted(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
img_block = result[0]["content"][0]
assert img_block["type"] == "image"
assert img_block["source"]["type"] == "url"
assert img_block["source"]["url"] == "https://example.com/img.jpg"
def test_text_only_messages_unchanged(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
messages = [{"role": "user", "content": "Hello"}]
result = _convert_openai_images_to_anthropic(messages)
assert result[0] is messages[0] # same object, not copied
def test_jpeg_media_type_parsed(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/="}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
assert result[0]["content"][0]["source"]["media_type"] == "image/jpeg"
+139
View File
@@ -0,0 +1,139 @@
"""Tests for focus_topic flowing through the compressor.
Verifies that _generate_summary and compress accept and use the focus_topic
parameter correctly. Inspired by Claude Code's /compact <focus>.
"""
from unittest.mock import MagicMock, patch
from agent.context_compressor import ContextCompressor
def _make_compressor():
"""Create a ContextCompressor with minimal state for testing."""
compressor = ContextCompressor.__new__(ContextCompressor)
compressor.protect_first_n = 2
compressor.protect_last_n = 5
compressor.tail_token_budget = 20000
compressor.context_length = 200000
compressor.threshold_percent = 0.80
compressor.threshold_tokens = 160000
compressor.max_summary_tokens = 10000
compressor.quiet_mode = True
compressor.compression_count = 0
compressor.last_prompt_tokens = 0
compressor._previous_summary = None
compressor._summary_failure_cooldown_until = 0.0
compressor.summary_model = None
return compressor
def test_focus_topic_injected_into_summary_prompt():
"""When focus_topic is provided, the LLM prompt includes focus guidance."""
compressor = _make_compressor()
turns = [
{"role": "user", "content": "Tell me about the database schema"},
{"role": "assistant", "content": "The schema has tables: users, orders, products."},
]
captured_prompt = {}
def mock_call_llm(**kwargs):
captured_prompt["messages"] = kwargs["messages"]
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "## Goal\nUnderstand DB schema."
return resp
with patch("agent.context_compressor.call_llm", mock_call_llm):
result = compressor._generate_summary(turns, focus_topic="database schema")
assert result is not None
prompt_text = captured_prompt["messages"][0]["content"]
assert 'FOCUS TOPIC: "database schema"' in prompt_text
assert "PRIORITISE" in prompt_text
assert "60-70%" in prompt_text
def test_no_focus_topic_no_injection():
"""Without focus_topic, the prompt doesn't contain focus guidance."""
compressor = _make_compressor()
turns = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]
captured_prompt = {}
def mock_call_llm(**kwargs):
captured_prompt["messages"] = kwargs["messages"]
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "## Goal\nGreeting."
return resp
with patch("agent.context_compressor.call_llm", mock_call_llm):
result = compressor._generate_summary(turns)
prompt_text = captured_prompt["messages"][0]["content"]
assert "FOCUS TOPIC" not in prompt_text
def test_compress_passes_focus_to_generate_summary():
"""compress() passes focus_topic through to _generate_summary."""
compressor = _make_compressor()
# Track what _generate_summary receives
received_kwargs = {}
original_generate = compressor._generate_summary
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000, focus_topic="authentication flow")
assert received_kwargs.get("focus_topic") == "authentication flow"
def test_compress_none_focus_by_default():
"""compress() passes None focus_topic by default."""
compressor = _make_compressor()
received_kwargs = {}
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000)
assert received_kwargs.get("focus_topic") is None
+31
View File
@@ -191,6 +191,37 @@ class TestNonStringContent:
kwargs = mock_call.call_args.kwargs
assert "temperature" not in kwargs
def test_summary_call_passes_live_main_runtime(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "ok"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="gpt-5.4",
provider="openai-codex",
base_url="https://chatgpt.com/backend-api/codex",
api_key="codex-token",
api_mode="codex_responses",
quiet_mode=True,
)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call:
c._generate_summary(messages)
assert mock_call.call_args.kwargs["main_runtime"] == {
"model": "gpt-5.4",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "codex-token",
"api_mode": "codex_responses",
}
class TestSummaryFailureCooldown:
def test_summary_failure_enters_cooldown_and_skips_retry(self):
+4 -1
View File
@@ -87,7 +87,10 @@ class TestProviderMapping:
def test_unmapped_provider_not_in_dict(self):
assert "nous" not in PROVIDER_TO_MODELS_DEV
assert "openai-codex" not in PROVIDER_TO_MODELS_DEV
def test_openai_codex_mapped_to_openai(self):
assert PROVIDER_TO_MODELS_DEV["openai"] == "openai"
assert PROVIDER_TO_MODELS_DEV["openai-codex"] == "openai"
class TestExtractContext:
+25
View File
@@ -18,6 +18,7 @@ from agent.prompt_builder import (
build_skills_system_prompt,
build_nous_subscription_prompt,
build_context_files_prompt,
build_environment_hints,
CONTEXT_FILE_MAX_CHARS,
DEFAULT_AGENT_IDENTITY,
TOOL_USE_ENFORCEMENT_GUIDANCE,
@@ -26,6 +27,7 @@ from agent.prompt_builder import (
MEMORY_GUIDANCE,
SESSION_SEARCH_GUIDANCE,
PLATFORM_HINTS,
WSL_ENVIRONMENT_HINT,
)
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
@@ -770,6 +772,29 @@ class TestPromptBuilderConstants:
assert "cli" in PLATFORM_HINTS
# =========================================================================
# Environment hints
# =========================================================================
class TestEnvironmentHints:
def test_wsl_hint_constant_mentions_mnt(self):
assert "/mnt/c/" in WSL_ENVIRONMENT_HINT
assert "WSL" in WSL_ENVIRONMENT_HINT
def test_build_environment_hints_on_wsl(self, monkeypatch):
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: True)
result = _pb.build_environment_hints()
assert "/mnt/" in result
assert "WSL" in result
def test_build_environment_hints_not_wsl(self, monkeypatch):
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
result = _pb.build_environment_hints()
assert result == ""
# =========================================================================
# Conditional skill activation
# =========================================================================
+118
View File
@@ -0,0 +1,118 @@
"""Tests for /compress <focus> — guided compression with focus topic.
Inspired by Claude Code's /compact <focus> feature.
"""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_focus_topic_extracted_and_passed(capsys):
"""Focus topic is extracted from the command and passed to _compress_context."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
def _estimate(messages):
if messages is history:
return 100
return 50
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
shell._manual_compress("/compress database schema")
output = capsys.readouterr().out
assert 'focus: "database schema"' in output
# Verify focus_topic was passed through
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
def test_no_focus_topic_when_bare_command(capsys):
"""When no focus topic is provided, None is passed."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_empty_focus_after_command_treated_as_none(capsys):
"""Trailing whitespace after /compress does not produce a focus topic."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress ")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_focus_topic_printed_in_compression_banner(capsys):
"""The focus topic shows in the compression progress banner."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress API endpoints")
output = capsys.readouterr().out
assert 'focus: "API endpoints"' in output
def test_no_focus_prints_standard_banner(capsys):
"""Without focus, the standard banner (no focus: line) is printed."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
output = capsys.readouterr().out
assert "focus:" not in output
assert "Compressing" in output
+189
View File
@@ -0,0 +1,189 @@
"""Tests for stacked tool progress scrollback lines in the CLI TUI.
When tool_progress_mode is "all" or "new", _on_tool_progress should print
persistent lines to scrollback on tool.completed, restoring the stacked
tool history that was lost when the TUI switched to a single-line spinner.
"""
import os
import sys
import importlib
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Module-level reference to the cli module (set by _make_cli on first call)
_cli_mod = None
def _make_cli(tool_progress="all"):
"""Create a HermesCLI instance with minimal mocking."""
global _cli_mod
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": tool_progress},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as mod
mod = importlib.reload(mod)
_cli_mod = mod
with patch.object(mod, "get_tool_definitions", return_value=[]), \
patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
return mod.HermesCLI()
class TestToolProgressScrollback:
"""Stacked scrollback lines for 'all' and 'new' modes."""
def test_all_mode_prints_scrollback_on_completed(self):
"""In 'all' mode, tool.completed prints a stacked line."""
cli = _make_cli(tool_progress="all")
# Simulate tool.started
cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"})
# Simulate tool.completed
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False)
mock_print.assert_called_once()
line = mock_print.call_args[0][0]
# Should contain tool info (the cute message format has "git log" for terminal)
assert "git log" in line or "$" in line
def test_all_mode_prints_every_call(self):
"""In 'all' mode, consecutive calls to the same tool each get a line."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# First call
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
# Second call (same tool)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 2
def test_new_mode_skips_consecutive_repeats(self):
"""In 'new' mode, consecutive calls to the same tool only print once."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 1 # Only the first read_file
def test_new_mode_prints_when_tool_changes(self):
"""In 'new' mode, a different tool name triggers a new line."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"})
cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
# read_file, search_files, read_file (3rd prints because search_files broke the streak)
assert mock_print.call_count == 3
def test_off_mode_no_scrollback(self):
"""In 'off' mode, no stacked lines are printed."""
cli = _make_cli(tool_progress="off")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_not_called()
def test_error_suffix_on_failed_tool(self):
"""When is_error=True, the stacked line includes [error]."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"})
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True)
line = mock_print.call_args[0][0]
assert "[error]" in line
def test_spinner_still_updates_on_started(self):
"""tool.started still updates the spinner text for live display."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert "git status" in cli._spinner_text
def test_spinner_timer_clears_on_completed(self):
"""tool.completed still clears the tool timer."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert cli._tool_start_time > 0
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
assert cli._tool_start_time == 0.0
def test_concurrent_tools_produce_stacked_lines(self):
"""Multiple tool.started followed by multiple tool.completed all produce lines."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# All start first (concurrent pattern)
cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"})
cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"})
# All complete
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False)
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False)
assert mock_print.call_count == 2
def test_verbose_mode_no_duplicate_scrollback(self):
"""In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output)."""
cli = _make_cli(tool_progress="verbose")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_not_called()
def test_pending_info_stores_on_started(self):
"""tool.started stores args for later use by tool.completed."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
assert "terminal" in cli._pending_tool_info
assert len(cli._pending_tool_info["terminal"]) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "ls"}
def test_pending_info_consumed_on_completed(self):
"""tool.completed consumes stored args (FIFO for concurrent)."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"})
assert len(cli._pending_tool_info["terminal"]) == 2
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
# First entry consumed, second remains
assert len(cli._pending_tool_info.get("terminal", [])) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}
+226
View File
@@ -0,0 +1,226 @@
"""Tests for the clean shutdown marker that prevents unwanted session auto-resets.
When the gateway shuts down gracefully (hermes update, gateway restart, /restart),
it writes a .clean_shutdown marker. On the next startup, if the marker exists,
suspend_recently_active() is skipped so users don't lose their sessions.
After a crash (no marker), suspension still fires as a safety net for stuck sessions.
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig, SessionResetPolicy
from gateway.session import SessionEntry, SessionSource, SessionStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_source(platform=Platform.TELEGRAM, chat_id="123", user_id="u1"):
return SessionSource(platform=platform, chat_id=chat_id, user_id=user_id)
def _make_store(tmp_path, policy=None):
config = GatewayConfig()
if policy:
config.default_reset_policy = policy
return SessionStore(sessions_dir=tmp_path, config=config)
# ---------------------------------------------------------------------------
# SessionStore.suspend_recently_active
# ---------------------------------------------------------------------------
class TestSuspendRecentlyActive:
"""Verify suspend_recently_active only marks recent sessions."""
def test_suspends_recently_active_sessions(self, tmp_path):
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
assert not entry.suspended
count = store.suspend_recently_active()
assert count == 1
# Re-fetch — should be suspended now
refreshed = store.get_or_create_session(source)
assert refreshed.was_auto_reset
def test_does_not_suspend_old_sessions(self, tmp_path):
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
# Backdate the session's updated_at beyond the cutoff
with store._lock:
entry.updated_at = datetime.now() - timedelta(seconds=300)
store._save()
count = store.suspend_recently_active(max_age_seconds=120)
assert count == 0
def test_already_suspended_not_double_counted(self, tmp_path):
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
# Suspend once
count1 = store.suspend_recently_active()
assert count1 == 1
# Create a new session (the old one got reset on next access)
entry2 = store.get_or_create_session(source)
# Suspend again — the new session is recent but not yet suspended
count2 = store.suspend_recently_active()
assert count2 == 1
# ---------------------------------------------------------------------------
# Clean shutdown marker integration
# ---------------------------------------------------------------------------
class TestCleanShutdownMarker:
"""Test that the marker file controls session suspension on startup."""
def test_marker_written_on_graceful_stop(self, tmp_path, monkeypatch):
"""stop() should write .clean_shutdown marker."""
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
marker = tmp_path / ".clean_shutdown"
assert not marker.exists()
# Create a minimal runner and call the shutdown logic directly
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._restart_requested = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_task_started = False
runner._running = True
runner._draining = False
runner._stop_task = None
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._background_tasks = set()
runner._shutdown_event = MagicMock()
runner._restart_drain_timeout = 5
runner._exit_code = None
runner._exit_reason = None
runner.adapters = {}
runner.config = GatewayConfig()
# Mock heavy dependencies
with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \
patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \
patch("gateway.run.GatewayRunner._update_runtime_status"), \
patch("gateway.status.remove_pid_file"), \
patch("tools.process_registry.process_registry") as mock_proc_reg, \
patch("tools.terminal_tool.cleanup_all_environments"), \
patch("tools.browser_tool.cleanup_all_browsers"):
mock_proc_reg.kill_all = MagicMock()
import asyncio
asyncio.get_event_loop().run_until_complete(runner.stop())
assert marker.exists(), ".clean_shutdown marker should exist after graceful stop"
def test_marker_skips_suspension_on_startup(self, tmp_path, monkeypatch):
"""If .clean_shutdown exists, suspend_recently_active should NOT be called."""
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
# Create the marker
marker = tmp_path / ".clean_shutdown"
marker.touch()
# Create a store with a recently active session
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
assert not entry.suspended
# Simulate what start() does:
if marker.exists():
marker.unlink()
# Should NOT call suspend_recently_active
else:
store.suspend_recently_active()
# Session should NOT be suspended
with store._lock:
store._ensure_loaded_locked()
for e in store._entries.values():
assert not e.suspended, "Session should NOT be suspended after clean shutdown"
assert not marker.exists(), "Marker should be cleaned up"
def test_no_marker_triggers_suspension(self, tmp_path, monkeypatch):
"""Without .clean_shutdown marker (crash), suspension should fire."""
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
marker = tmp_path / ".clean_shutdown"
assert not marker.exists()
# Create a store with a recently active session
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
assert not entry.suspended
# Simulate what start() does:
if marker.exists():
marker.unlink()
else:
store.suspend_recently_active()
# Session SHOULD be suspended (crash recovery)
with store._lock:
store._ensure_loaded_locked()
suspended_count = sum(1 for e in store._entries.values() if e.suspended)
assert suspended_count == 1, "Session should be suspended after crash (no marker)"
def test_marker_written_on_restart_stop(self, tmp_path, monkeypatch):
"""stop(restart=True) should also write the marker."""
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
marker = tmp_path / ".clean_shutdown"
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._restart_requested = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_task_started = False
runner._running = True
runner._draining = False
runner._stop_task = None
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._background_tasks = set()
runner._shutdown_event = MagicMock()
runner._restart_drain_timeout = 5
runner._exit_code = None
runner._exit_reason = None
runner.adapters = {}
runner.config = GatewayConfig()
with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \
patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \
patch("gateway.run.GatewayRunner._update_runtime_status"), \
patch("gateway.status.remove_pid_file"), \
patch("tools.process_registry.process_registry") as mock_proc_reg, \
patch("tools.terminal_tool.cleanup_all_environments"), \
patch("tools.browser_tool.cleanup_all_browsers"):
mock_proc_reg.kill_all = MagicMock()
import asyncio
asyncio.get_event_loop().run_until_complete(runner.stop(restart=True))
assert marker.exists(), ".clean_shutdown marker should exist after restart-stop too"
+118
View File
@@ -0,0 +1,118 @@
"""Tests for gateway /compress <focus> — focus topic on the gateway side."""
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str = "/compress") -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def _make_runner(history: list[dict[str, str]]):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
session_entry = SessionEntry(
session_key=build_session_key(_make_source()),
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = history
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner.session_store._save = MagicMock()
return runner
@pytest.mark.asyncio
async def test_compress_focus_topic_passed_to_agent():
"""Focus topic from /compress <focus> is passed through to _compress_context."""
history = _make_history()
compressed = [history[0], history[-1]]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
def _estimate(messages):
return 100
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event("/compress database schema"))
# Verify focus_topic was passed
agent_instance._compress_context.assert_called_once()
call_kwargs = agent_instance._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
# Verify focus is mentioned in response
assert 'Focus: "database schema"' in result
@pytest.mark.asyncio
async def test_compress_no_focus_passes_none():
"""Bare /compress passes focus_topic=None."""
history = _make_history()
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
):
result = await runner._handle_compress_command(_make_event("/compress"))
agent_instance._compress_context.assert_called_once()
call_kwargs = agent_instance._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
# No focus line in response
assert "Focus:" not in result
+53
View File
@@ -74,6 +74,26 @@ class FakeBot:
return None
class SlowSyncTree(FakeTree):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.allow_finish = asyncio.Event()
async def _slow_sync():
self.started.set()
await self.allow_finish.wait()
return []
self.sync = AsyncMock(side_effect=_slow_sync)
class SlowSyncBot(FakeBot):
def __init__(self, *, intents, proxy=None):
super().__init__(intents=intents, proxy=proxy)
self.tree = SlowSyncTree()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("allowed_users", "expected_members_intent"),
@@ -138,3 +158,36 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
assert ok is False
assert released == [("discord-bot-token", "test-token")]
assert adapter._platform_lock_identity is None
@pytest.mark.asyncio
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
created = {}
def fake_bot_factory(*, command_prefix, intents, proxy=None):
bot = SlowSyncBot(intents=intents, proxy=proxy)
created["bot"] = bot
return bot
monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
ok = await asyncio.wait_for(adapter.connect(), timeout=1.0)
assert ok is True
assert adapter._ready_event.is_set()
await asyncio.wait_for(created["bot"].tree.started.wait(), timeout=1.0)
assert created["bot"].tree.sync.await_count == 1
created["bot"].tree.allow_finish.set()
await asyncio.sleep(0)
await adapter.disconnect()
@@ -28,12 +28,16 @@ class _FakeRegistry:
def __init__(self, sessions):
self._sessions = list(sessions)
self._completion_consumed: set = set()
def get(self, session_id):
if self._sessions:
return self._sessions.pop(0)
return None
def is_completion_consumed(self, session_id):
return session_id in self._completion_consumed
def _build_runner(monkeypatch, tmp_path) -> GatewayRunner:
"""Create a GatewayRunner with notifications set to 'all'."""
+62 -5
View File
@@ -157,12 +157,44 @@ def _make_fake_mautrix():
mautrix_crypto_store = types.ModuleType("mautrix.crypto.store")
class MemoryCryptoStore:
def __init__(self, account_id="", pickle_key=""):
def __init__(self, account_id="", pickle_key=""): # noqa: S301
self.account_id = account_id
self.pickle_key = pickle_key
mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore
# --- mautrix.crypto.store.asyncpg ---
mautrix_crypto_store_asyncpg = types.ModuleType("mautrix.crypto.store.asyncpg")
class PgCryptoStore:
upgrade_table = MagicMock()
def __init__(self, account_id="", pickle_key="", db=None): # noqa: S301
self.account_id = account_id
self.pickle_key = pickle_key
self.db = db
async def open(self):
pass
mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore
# --- mautrix.util ---
mautrix_util = types.ModuleType("mautrix.util")
# --- mautrix.util.async_db ---
mautrix_util_async_db = types.ModuleType("mautrix.util.async_db")
class Database:
@classmethod
def create(cls, url, upgrade_table=None):
db = MagicMock()
db.start = AsyncMock()
db.stop = AsyncMock()
return db
mautrix_util_async_db.Database = Database
return {
"mautrix": mautrix,
"mautrix.api": mautrix_api,
@@ -171,6 +203,9 @@ def _make_fake_mautrix():
"mautrix.client.state_store": mautrix_client_state_store,
"mautrix.crypto": mautrix_crypto,
"mautrix.crypto.store": mautrix_crypto_store,
"mautrix.crypto.store.asyncpg": mautrix_crypto_store_asyncpg,
"mautrix.util": mautrix_util,
"mautrix.util.async_db": mautrix_util_async_db,
}
@@ -740,6 +775,12 @@ class TestMatrixAccessTokenAuth:
mock_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123"))
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
mock_client.add_event_handler = MagicMock()
mock_client.handle_sync = MagicMock(return_value=[])
mock_client.query_keys = AsyncMock(return_value={
"device_keys": {"@bot:example.org": {"DEV123": {
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
}}},
})
mock_client.api = MagicMock()
mock_client.api.token = "syt_test_access_token"
mock_client.api.session = MagicMock()
@@ -751,6 +792,8 @@ class TestMatrixAccessTokenAuth:
mock_olm.share_keys = AsyncMock()
mock_olm.share_keys_min_trust = None
mock_olm.send_keys_min_trust = None
mock_olm.account = MagicMock()
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
# Patch Client constructor to return our mock
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
@@ -924,6 +967,12 @@ class TestMatrixDeviceId:
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="WHOAMI_DEV"))
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
mock_client.add_event_handler = MagicMock()
mock_client.handle_sync = MagicMock(return_value=[])
mock_client.query_keys = AsyncMock(return_value={
"device_keys": {"@bot:example.org": {"MY_STABLE_DEVICE": {
"keys": {"ed25519:MY_STABLE_DEVICE": "fake_ed25519_key"},
}}},
})
mock_client.api = MagicMock()
mock_client.api.token = "syt_test_access_token"
mock_client.api.session = MagicMock()
@@ -934,6 +983,8 @@ class TestMatrixDeviceId:
mock_olm.share_keys = AsyncMock()
mock_olm.share_keys_min_trust = None
mock_olm.send_keys_min_trust = None
mock_olm.account = MagicMock()
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
@@ -1030,8 +1081,8 @@ class TestMatrixDeviceIdConfig:
class TestMatrixSyncLoop:
@pytest.mark.asyncio
async def test_sync_loop_shares_keys_when_encryption_enabled(self):
"""_sync_loop should call crypto.share_keys() after each sync."""
async def test_sync_loop_dispatches_events_and_stores_token(self):
"""_sync_loop should call handle_sync() and persist next_batch."""
adapter = _make_adapter()
adapter._encryption = True
adapter._closing = False
@@ -1046,7 +1097,6 @@ class TestMatrixSyncLoop:
return {"rooms": {"join": {"!room:example.org": {}}}, "next_batch": "s1234"}
mock_crypto = MagicMock()
mock_crypto.share_keys = AsyncMock()
mock_sync_store = MagicMock()
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
@@ -1062,7 +1112,6 @@ class TestMatrixSyncLoop:
await adapter._sync_loop()
fake_client.sync.assert_awaited_once()
mock_crypto.share_keys.assert_awaited_once()
fake_client.handle_sync.assert_called_once()
mock_sync_store.put_next_batch.assert_awaited_once_with("s1234")
@@ -1248,6 +1297,12 @@ class TestMatrixEncryptedEventHandler:
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
mock_client.add_event_handler = MagicMock()
mock_client.handle_sync = MagicMock(return_value=[])
mock_client.query_keys = AsyncMock(return_value={
"device_keys": {"@bot:example.org": {"DEV123": {
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
}}},
})
mock_client.api = MagicMock()
mock_client.api.token = "syt_test_token"
mock_client.api.session = MagicMock()
@@ -1258,6 +1313,8 @@ class TestMatrixEncryptedEventHandler:
mock_olm.share_keys = AsyncMock()
mock_olm.share_keys_min_trust = None
mock_olm.send_keys_min_trust = None
mock_olm.account = MagicMock()
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
+77 -1
View File
@@ -1,4 +1,5 @@
import pytest
from unittest.mock import AsyncMock
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter
@@ -45,6 +46,23 @@ class _DisabledAdapter(BasePlatformAdapter):
return {"id": chat_id}
class _SuccessfulAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.DISCORD)
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
raise NotImplementedError
async def get_chat_info(self, chat_id):
return {"id": chat_id}
@pytest.mark.asyncio
async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -65,7 +83,7 @@ async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch,
state = read_runtime_status()
assert state["gateway_state"] == "startup_failed"
assert "temporary DNS resolution failure" in state["exit_reason"]
assert state["platforms"]["telegram"]["state"] == "fatal"
assert state["platforms"]["telegram"]["state"] == "retrying"
assert state["platforms"]["telegram"]["error_code"] == "telegram_connect_error"
@@ -89,6 +107,64 @@ async def test_runner_allows_cron_only_mode_when_no_platforms_are_enabled(monkey
assert state["gateway_state"] == "running"
@pytest.mark.asyncio
async def test_runner_records_connected_platform_state_on_success(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = GatewayConfig(
platforms={
Platform.DISCORD: PlatformConfig(enabled=True, token="***")
},
sessions_dir=tmp_path / "sessions",
)
runner = GatewayRunner(config)
monkeypatch.setattr(runner, "_create_adapter", lambda platform, platform_config: _SuccessfulAdapter())
monkeypatch.setattr(runner.hooks, "discover_and_load", lambda: None)
monkeypatch.setattr(runner.hooks, "emit", AsyncMock())
ok = await runner.start()
assert ok is True
state = read_runtime_status()
assert state["gateway_state"] == "running"
assert state["platforms"]["discord"]["state"] == "connected"
assert state["platforms"]["discord"]["error_code"] is None
assert state["platforms"]["discord"]["error_message"] is None
@pytest.mark.asyncio
async def test_start_gateway_verbosity_imports_redacting_formatter(monkeypatch, tmp_path):
"""Verbosity != None must not crash with NameError on RedactingFormatter (#8044)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
class _CleanExitRunner:
def __init__(self, config):
self.config = config
self.should_exit_cleanly = True
self.exit_reason = None
self.adapters = {}
async def start(self):
return True
async def stop(self):
return None
monkeypatch.setattr("gateway.status.get_running_pid", lambda: None)
monkeypatch.setattr("tools.skills_sync.sync_skills", lambda quiet=True: None)
monkeypatch.setattr("hermes_logging.setup_logging", lambda hermes_home, mode: tmp_path)
monkeypatch.setattr("hermes_logging._add_rotating_handler", lambda *args, **kwargs: None)
monkeypatch.setattr("gateway.run.GatewayRunner", _CleanExitRunner)
from gateway.run import start_gateway
# verbosity=1 triggers the code path that uses RedactingFormatter.
# Before the fix this raised NameError.
ok = await start_gateway(config=GatewayConfig(), replace=False, verbosity=1)
assert ok is True
@pytest.mark.asyncio
async def test_start_gateway_replace_force_uses_terminate_pid(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+28
View File
@@ -104,6 +104,34 @@ class TestGatewayRuntimeStatus:
assert payload["platforms"]["telegram"]["error_code"] == "telegram_polling_conflict"
assert payload["platforms"]["telegram"]["error_message"] == "another poller is active"
def test_write_runtime_status_explicit_none_clears_stale_fields(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
status.write_runtime_status(
gateway_state="startup_failed",
exit_reason="stale error",
platform="discord",
platform_state="fatal",
error_code="discord_timeout",
error_message="stale platform error",
)
status.write_runtime_status(
gateway_state="running",
exit_reason=None,
platform="discord",
platform_state="connected",
error_code=None,
error_message=None,
)
payload = status.read_runtime_status()
assert payload["gateway_state"] == "running"
assert payload["exit_reason"] is None
assert payload["platforms"]["discord"]["state"] == "connected"
assert payload["platforms"]["discord"]["error_code"] is None
assert payload["platforms"]["discord"]["error_message"] is None
class TestTerminatePid:
def test_force_uses_taskkill_on_windows(self, monkeypatch):
+50
View File
@@ -403,6 +403,56 @@ class TestWatchUpdateProgress:
# Should not crash; legacy notification handles this case
@pytest.mark.asyncio
async def test_prompt_forwarded_only_once(self, tmp_path):
"""Regression: prompt must not be re-sent on every poll cycle.
Before the fix, the watcher never deleted .update_prompt.json after
forwarding, causing the same prompt to be sent every poll_interval.
"""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222",
"session_key": "agent:main:telegram:dm:111"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
# Write the prompt file up front (before the watcher starts).
# The watcher should forward it exactly once, then delete it.
prompt = {"prompt": "Would you like to configure new options now? Y/n",
"default": "n", "id": "dup-test"}
(hermes_home / ".update_prompt.json").write_text(json.dumps(prompt))
async def finish_after_polls():
# Wait long enough for multiple poll cycles to occur, then
# simulate a response + completion.
await asyncio.sleep(1.0)
(hermes_home / ".update_response").write_text("n")
await asyncio.sleep(0.3)
(hermes_home / ".update_exit_code").write_text("0")
with patch("gateway.run._hermes_home", hermes_home):
task = asyncio.create_task(finish_after_polls())
await runner._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=10.0,
)
await task
# Count how many times the prompt text was sent
all_sent = [str(c) for c in mock_adapter.send.call_args_list]
prompt_sends = [s for s in all_sent if "configure new options" in s]
assert len(prompt_sends) == 1, (
f"Prompt was sent {len(prompt_sends)} times (expected 1). "
f"All sends: {all_sent}"
)
# ---------------------------------------------------------------------------
# Message interception for update prompts
+33 -2
View File
@@ -64,13 +64,44 @@ class TestWeixinFormatting:
class TestWeixinChunking:
def test_split_text_keeps_short_multiline_message_in_single_chunk(self):
def test_split_text_splits_short_chatty_replies_into_separate_bubbles(self):
adapter = _make_adapter()
content = adapter.format_message("第一行\n第二行\n第三行")
chunks = adapter._split_text(content)
assert chunks == ["第一行\n第二行\n第三行"]
assert chunks == ["第一行", "第二行", "第三行"]
def test_split_text_keeps_structured_table_block_together(self):
adapter = _make_adapter()
content = adapter.format_message(
"- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"
)
chunks = adapter._split_text(content)
assert chunks == ["- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"]
def test_split_text_keeps_four_line_structured_blocks_together(self):
adapter = _make_adapter()
content = adapter.format_message(
"今天结论:\n"
"- 留存下降 3%\n"
"- 转化上涨 8%\n"
"- 主要问题在首日激活"
)
chunks = adapter._split_text(content)
assert chunks == ["今天结论:\n- 留存下降 3%\n- 转化上涨 8%\n- 主要问题在首日激活"]
def test_split_text_keeps_heading_with_body_together(self):
adapter = _make_adapter()
content = adapter.format_message("## 结论\n这是正文")
chunks = adapter._split_text(content)
assert chunks == ["**结论**\n这是正文"]
def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
adapter = _make_adapter()
+95 -2
View File
@@ -14,6 +14,7 @@ from hermes_cli.auth import (
PROVIDER_REGISTRY,
_read_codex_tokens,
_save_codex_tokens,
_write_codex_cli_tokens,
_import_codex_cli_tokens,
get_codex_auth_status,
get_provider_auth_state,
@@ -161,7 +162,7 @@ def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch):
def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
"""Verify Hermes never writes to ~/.codex/auth.json."""
"""Verify _save_codex_tokens writes only to Hermes auth store, not ~/.codex/."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex-cli"
hermes_home.mkdir(parents=True, exist_ok=True)
@@ -173,7 +174,7 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
_save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"})
# ~/.codex/auth.json should NOT exist
# ~/.codex/auth.json should NOT exist — _save_codex_tokens only touches Hermes store
assert not (codex_home / "auth.json").exists()
# Hermes auth store should have the tokens
@@ -181,6 +182,98 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
assert data["tokens"]["access_token"] == "hermes-at"
def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch):
"""_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens."""
codex_home = tmp_path / "codex-cli"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
_write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z")
auth_path = codex_home / "auth.json"
assert auth_path.exists()
data = json.loads(auth_path.read_text())
assert data["tokens"]["access_token"] == "new-access"
assert data["tokens"]["refresh_token"] == "new-refresh"
assert data["last_refresh"] == "2026-04-12T00:00:00Z"
# Verify file permissions are restricted
assert (auth_path.stat().st_mode & 0o777) == 0o600
def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch):
"""_write_codex_cli_tokens preserves extra fields in existing auth.json."""
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
existing = {
"tokens": {
"access_token": "old-access",
"refresh_token": "old-refresh",
"extra_field": "preserved",
},
"last_refresh": "2026-01-01T00:00:00Z",
"custom_key": "keep_me",
}
(codex_home / "auth.json").write_text(json.dumps(existing))
_write_codex_cli_tokens("updated-access", "updated-refresh")
data = json.loads((codex_home / "auth.json").read_text())
assert data["tokens"]["access_token"] == "updated-access"
assert data["tokens"]["refresh_token"] == "updated-refresh"
assert data["tokens"]["extra_field"] == "preserved"
assert data["custom_key"] == "keep_me"
# last_refresh not updated since we didn't pass it
assert data["last_refresh"] == "2026-01-01T00:00:00Z"
def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch):
"""_write_codex_cli_tokens creates parent directories if missing."""
codex_home = tmp_path / "does" / "not" / "exist"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
_write_codex_cli_tokens("at", "rt")
assert (codex_home / "auth.json").exists()
data = json.loads((codex_home / "auth.json").read_text())
assert data["tokens"]["access_token"] == "at"
def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch):
"""After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json."""
from hermes_cli.auth import _refresh_codex_auth_tokens
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex-cli"
hermes_home.mkdir(parents=True, exist_ok=True)
codex_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
# Write initial CLI tokens
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
}))
# Mock the pure refresh to return new tokens
monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: {
"access_token": "refreshed-at",
"refresh_token": "refreshed-rt",
"last_refresh": "2026-04-12T01:00:00Z",
})
_refresh_codex_auth_tokens(
{"access_token": "old-at", "refresh_token": "old-rt"},
timeout_seconds=10,
)
# Verify CLI file was updated
cli_data = json.loads((codex_home / "auth.json").read_text())
assert cli_data["tokens"]["access_token"] == "refreshed-at"
assert cli_data["tokens"]["refresh_token"] == "refreshed-rt"
def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home)
+897
View File
@@ -0,0 +1,897 @@
"""Tests for hermes backup and import commands."""
import os
import zipfile
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_hermes_tree(root: Path) -> None:
"""Create a realistic ~/.hermes directory structure for testing."""
(root / "config.yaml").write_text("model:\n provider: openrouter\n")
(root / ".env").write_text("OPENROUTER_API_KEY=sk-test-123\n")
(root / "memory_store.db").write_bytes(b"fake-sqlite")
(root / "hermes_state.db").write_bytes(b"fake-state")
# Sessions
(root / "sessions").mkdir(exist_ok=True)
(root / "sessions" / "abc123.json").write_text("{}")
# Skills
(root / "skills").mkdir(exist_ok=True)
(root / "skills" / "my-skill").mkdir()
(root / "skills" / "my-skill" / "SKILL.md").write_text("# My Skill\n")
# Skins
(root / "skins").mkdir(exist_ok=True)
(root / "skins" / "cyber.yaml").write_text("name: cyber\n")
# Cron
(root / "cron").mkdir(exist_ok=True)
(root / "cron" / "jobs.json").write_text("[]")
# Memories
(root / "memories").mkdir(exist_ok=True)
(root / "memories" / "notes.json").write_text("{}")
# Profiles
(root / "profiles").mkdir(exist_ok=True)
(root / "profiles" / "coder").mkdir()
(root / "profiles" / "coder" / "config.yaml").write_text("model:\n provider: anthropic\n")
(root / "profiles" / "coder" / ".env").write_text("ANTHROPIC_API_KEY=sk-ant-123\n")
# hermes-agent repo (should be EXCLUDED)
(root / "hermes-agent").mkdir(exist_ok=True)
(root / "hermes-agent" / "run_agent.py").write_text("# big file\n")
(root / "hermes-agent" / ".git").mkdir()
(root / "hermes-agent" / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
# __pycache__ (should be EXCLUDED)
(root / "plugins").mkdir(exist_ok=True)
(root / "plugins" / "__pycache__").mkdir()
(root / "plugins" / "__pycache__" / "mod.cpython-312.pyc").write_bytes(b"\x00")
# PID files (should be EXCLUDED)
(root / "gateway.pid").write_text("12345")
# Logs (should be included)
(root / "logs").mkdir(exist_ok=True)
(root / "logs" / "agent.log").write_text("log line\n")
# ---------------------------------------------------------------------------
# _should_exclude tests
# ---------------------------------------------------------------------------
class TestShouldExclude:
def test_excludes_hermes_agent(self):
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("hermes-agent/run_agent.py"))
assert _should_exclude(Path("hermes-agent/.git/HEAD"))
def test_excludes_pycache(self):
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("plugins/__pycache__/mod.cpython-312.pyc"))
def test_excludes_pyc_files(self):
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("some/module.pyc"))
def test_excludes_pid_files(self):
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("gateway.pid"))
assert _should_exclude(Path("cron.pid"))
def test_includes_config(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("config.yaml"))
def test_includes_env(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path(".env"))
def test_includes_skills(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("skills/my-skill/SKILL.md"))
def test_includes_profiles(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("profiles/coder/config.yaml"))
def test_includes_sessions(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("sessions/abc.json"))
def test_includes_logs(self):
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("logs/agent.log"))
# ---------------------------------------------------------------------------
# Backup tests
# ---------------------------------------------------------------------------
class TestBackup:
def test_creates_zip(self, tmp_path, monkeypatch):
"""Backup creates a valid zip containing expected files."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# get_default_hermes_root needs this
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
assert out_zip.exists()
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
# Config should be present
assert "config.yaml" in names
assert ".env" in names
# Skills
assert "skills/my-skill/SKILL.md" in names
# Profiles
assert "profiles/coder/config.yaml" in names
assert "profiles/coder/.env" in names
# Sessions
assert "sessions/abc123.json" in names
# Logs
assert "logs/agent.log" in names
# Skins
assert "skins/cyber.yaml" in names
def test_excludes_hermes_agent(self, tmp_path, monkeypatch):
"""Backup does NOT include hermes-agent/ directory."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
agent_files = [n for n in names if "hermes-agent" in n]
assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}"
def test_excludes_pycache(self, tmp_path, monkeypatch):
"""Backup does NOT include __pycache__ dirs."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
pycache_files = [n for n in names if "__pycache__" in n]
assert pycache_files == []
def test_excludes_pid_files(self, tmp_path, monkeypatch):
"""Backup does NOT include PID files."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
pid_files = [n for n in names if n.endswith(".pid")]
assert pid_files == []
def test_default_output_path(self, tmp_path, monkeypatch):
"""When no output path given, zip goes to ~/hermes-backup-*.zip."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
args = Namespace(output=None)
from hermes_cli.backup import run_backup
run_backup(args)
# Should exist in home dir
zips = list(tmp_path.glob("hermes-backup-*.zip"))
assert len(zips) == 1
# ---------------------------------------------------------------------------
# Import tests
# ---------------------------------------------------------------------------
class TestImport:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
"""Create a test zip with given files."""
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
if isinstance(content, bytes):
zf.writestr(name, content)
else:
zf.writestr(name, content)
def test_restores_files(self, tmp_path, monkeypatch):
"""Import extracts files into hermes home."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model:\n provider: openrouter\n",
".env": "OPENROUTER_API_KEY=sk-test\n",
"skills/my-skill/SKILL.md": "# My Skill\n",
"profiles/coder/config.yaml": "model:\n provider: anthropic\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
assert (hermes_home / "config.yaml").read_text() == "model:\n provider: openrouter\n"
assert (hermes_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test\n"
assert (hermes_home / "skills" / "my-skill" / "SKILL.md").read_text() == "# My Skill\n"
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
def test_strips_hermes_prefix(self, tmp_path, monkeypatch):
"""Import strips .hermes/ prefix if all entries share it."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
".hermes/config.yaml": "model: test\n",
".hermes/skills/a/SKILL.md": "# A\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
assert (hermes_home / "config.yaml").read_text() == "model: test\n"
assert (hermes_home / "skills" / "a" / "SKILL.md").read_text() == "# A\n"
def test_rejects_empty_zip(self, tmp_path, monkeypatch):
"""Import rejects an empty zip."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "empty.zip"
with zipfile.ZipFile(zip_path, "w"):
pass # empty
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
with pytest.raises(SystemExit):
run_import(args)
def test_rejects_non_hermes_zip(self, tmp_path, monkeypatch):
"""Import rejects a zip that doesn't look like a hermes backup."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "random.zip"
self._make_backup_zip(zip_path, {
"some/random/file.txt": "hello",
"another/thing.json": "{}",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
with pytest.raises(SystemExit):
run_import(args)
def test_blocks_path_traversal(self, tmp_path, monkeypatch):
"""Import blocks zip entries with path traversal."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "evil.zip"
# Include a marker file so validation passes
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"../../etc/passwd": "root:x:0:0\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# config.yaml should be restored
assert (hermes_home / "config.yaml").exists()
# traversal file should NOT exist outside hermes home
assert not (tmp_path / "etc" / "passwd").exists()
def test_confirmation_prompt_abort(self, tmp_path, monkeypatch):
"""Import aborts when user says no to confirmation."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Pre-existing config triggers the confirmation
(hermes_home / "config.yaml").write_text("existing: true\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: restored\n",
})
args = Namespace(zipfile=str(zip_path), force=False)
from hermes_cli.backup import run_import
with patch("builtins.input", return_value="n"):
run_import(args)
# Original config should be unchanged
assert (hermes_home / "config.yaml").read_text() == "existing: true\n"
def test_force_skips_confirmation(self, tmp_path, monkeypatch):
"""Import with --force skips confirmation and overwrites."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("existing: true\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: restored\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
assert (hermes_home / "config.yaml").read_text() == "model: restored\n"
def test_missing_file_exits(self, tmp_path, monkeypatch):
"""Import exits with error for nonexistent file."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
args = Namespace(zipfile=str(tmp_path / "nonexistent.zip"), force=True)
from hermes_cli.backup import run_import
with pytest.raises(SystemExit):
run_import(args)
# ---------------------------------------------------------------------------
# Round-trip test
# ---------------------------------------------------------------------------
class TestRoundTrip:
def test_backup_then_import(self, tmp_path, monkeypatch):
"""Full round-trip: backup -> import to a new location -> verify."""
# Source
src_home = tmp_path / "source" / ".hermes"
src_home.mkdir(parents=True)
_make_hermes_tree(src_home)
monkeypatch.setenv("HERMES_HOME", str(src_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "source")
# Backup
out_zip = tmp_path / "roundtrip.zip"
from hermes_cli.backup import run_backup, run_import
run_backup(Namespace(output=str(out_zip)))
assert out_zip.exists()
# Import into a different location
dst_home = tmp_path / "dest" / ".hermes"
dst_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(dst_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "dest")
run_import(Namespace(zipfile=str(out_zip), force=True))
# Verify key files
assert (dst_home / "config.yaml").read_text() == "model:\n provider: openrouter\n"
assert (dst_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test-123\n"
assert (dst_home / "skills" / "my-skill" / "SKILL.md").exists()
assert (dst_home / "profiles" / "coder" / "config.yaml").exists()
assert (dst_home / "sessions" / "abc123.json").exists()
assert (dst_home / "logs" / "agent.log").exists()
# hermes-agent should NOT be present
assert not (dst_home / "hermes-agent").exists()
# __pycache__ should NOT be present
assert not (dst_home / "plugins" / "__pycache__").exists()
# PID files should NOT be present
assert not (dst_home / "gateway.pid").exists()
# ---------------------------------------------------------------------------
# Validate / detect-prefix unit tests
# ---------------------------------------------------------------------------
class TestFormatSize:
def test_bytes(self):
from hermes_cli.backup import _format_size
assert _format_size(512) == "512 B"
def test_kilobytes(self):
from hermes_cli.backup import _format_size
assert "KB" in _format_size(2048)
def test_megabytes(self):
from hermes_cli.backup import _format_size
assert "MB" in _format_size(5 * 1024 * 1024)
def test_gigabytes(self):
from hermes_cli.backup import _format_size
assert "GB" in _format_size(3 * 1024 ** 3)
def test_terabytes(self):
from hermes_cli.backup import _format_size
assert "TB" in _format_size(2 * 1024 ** 4)
class TestValidation:
def test_validate_with_config(self):
"""Zip with config.yaml passes validation."""
import io
from hermes_cli.backup import _validate_backup_zip
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("config.yaml", "test")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
assert ok
def test_validate_with_env(self):
"""Zip with .env passes validation."""
import io
from hermes_cli.backup import _validate_backup_zip
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(".env", "KEY=val")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
assert ok
def test_validate_rejects_random(self):
"""Zip without hermes markers fails validation."""
import io
from hermes_cli.backup import _validate_backup_zip
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("random/file.txt", "hello")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
assert not ok
def test_detect_prefix_hermes(self):
"""Detects .hermes/ prefix wrapping all entries."""
import io
from hermes_cli.backup import _detect_prefix
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(".hermes/config.yaml", "test")
zf.writestr(".hermes/skills/a/SKILL.md", "skill")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
assert _detect_prefix(zf) == ".hermes/"
def test_detect_prefix_none(self):
"""No prefix when entries are at root."""
import io
from hermes_cli.backup import _detect_prefix
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("config.yaml", "test")
zf.writestr("skills/a/SKILL.md", "skill")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
assert _detect_prefix(zf) == ""
def test_detect_prefix_only_dirs(self):
"""Prefix detection returns empty for zip with only directory entries."""
import io
from hermes_cli.backup import _detect_prefix
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
# Only directory entries (trailing slash)
zf.writestr(".hermes/", "")
zf.writestr(".hermes/skills/", "")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
assert _detect_prefix(zf) == ""
# ---------------------------------------------------------------------------
# Edge case tests for uncovered paths
# ---------------------------------------------------------------------------
class TestBackupEdgeCases:
def test_nonexistent_hermes_home(self, tmp_path, monkeypatch):
"""Backup exits when hermes home doesn't exist."""
fake_home = tmp_path / "nonexistent" / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(fake_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "nonexistent")
args = Namespace(output=str(tmp_path / "out.zip"))
from hermes_cli.backup import run_backup
with pytest.raises(SystemExit):
run_backup(args)
def test_output_is_directory(self, tmp_path, monkeypatch):
"""When output path is a directory, zip is created inside it."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_dir = tmp_path / "backups"
out_dir.mkdir()
args = Namespace(output=str(out_dir))
from hermes_cli.backup import run_backup
run_backup(args)
zips = list(out_dir.glob("hermes-backup-*.zip"))
assert len(zips) == 1
def test_output_without_zip_suffix(self, tmp_path, monkeypatch):
"""Output path without .zip gets suffix appended."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_path = tmp_path / "mybackup.tar"
args = Namespace(output=str(out_path))
from hermes_cli.backup import run_backup
run_backup(args)
# Should have .tar.zip suffix
assert (tmp_path / "mybackup.tar.zip").exists()
def test_empty_hermes_home(self, tmp_path, monkeypatch):
"""Backup handles empty hermes home (no files to back up)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Only excluded dirs, no actual files
(hermes_home / "__pycache__").mkdir()
(hermes_home / "__pycache__" / "foo.pyc").write_bytes(b"\x00")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
args = Namespace(output=str(tmp_path / "out.zip"))
from hermes_cli.backup import run_backup
run_backup(args)
# No zip should be created
assert not (tmp_path / "out.zip").exists()
def test_permission_error_during_backup(self, tmp_path, monkeypatch):
"""Backup handles permission errors gracefully."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
# Create an unreadable file
bad_file = hermes_home / "secret.db"
bad_file.write_text("data")
bad_file.chmod(0o000)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "out.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
try:
run_backup(args)
finally:
# Restore permissions for cleanup
bad_file.chmod(0o644)
# Zip should still be created with the readable files
assert out_zip.exists()
def test_skips_output_zip_inside_hermes(self, tmp_path, monkeypatch):
"""Backup skips its own output zip if it's inside hermes root."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Output inside hermes home
out_zip = hermes_home / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
# The zip should exist but not contain itself
assert out_zip.exists()
with zipfile.ZipFile(out_zip, "r") as zf:
assert "backup.zip" not in zf.namelist()
class TestImportEdgeCases:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
def test_not_a_zip(self, tmp_path, monkeypatch):
"""Import rejects a non-zip file."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
not_zip = tmp_path / "fake.zip"
not_zip.write_text("this is not a zip")
args = Namespace(zipfile=str(not_zip), force=True)
from hermes_cli.backup import run_import
with pytest.raises(SystemExit):
run_import(args)
def test_eof_during_confirmation(self, tmp_path, monkeypatch):
"""Import handles EOFError during confirmation prompt."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("existing\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "new\n"})
args = Namespace(zipfile=str(zip_path), force=False)
from hermes_cli.backup import run_import
with patch("builtins.input", side_effect=EOFError):
with pytest.raises(SystemExit):
run_import(args)
def test_keyboard_interrupt_during_confirmation(self, tmp_path, monkeypatch):
"""Import handles KeyboardInterrupt during confirmation prompt."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".env").write_text("KEY=val\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "new\n"})
args = Namespace(zipfile=str(zip_path), force=False)
from hermes_cli.backup import run_import
with patch("builtins.input", side_effect=KeyboardInterrupt):
with pytest.raises(SystemExit):
run_import(args)
def test_permission_error_during_import(self, tmp_path, monkeypatch):
"""Import handles permission errors during extraction."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Create a read-only directory so extraction fails
locked_dir = hermes_home / "locked"
locked_dir.mkdir()
locked_dir.chmod(0o555)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"locked/secret.txt": "data",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
try:
run_import(args)
finally:
locked_dir.chmod(0o755)
# config.yaml should still be restored despite the error
assert (hermes_home / "config.yaml").exists()
def test_progress_with_many_files(self, tmp_path, monkeypatch):
"""Import shows progress with 500+ files."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "big.zip"
files = {"config.yaml": "model: test\n"}
for i in range(600):
files[f"sessions/s{i:04d}.json"] = "{}"
self._make_backup_zip(zip_path, files)
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
assert (hermes_home / "config.yaml").exists()
assert (hermes_home / "sessions" / "s0599.json").exists()
# ---------------------------------------------------------------------------
# Profile restoration tests
# ---------------------------------------------------------------------------
class TestProfileRestoration:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
def test_import_creates_profile_wrappers(self, tmp_path, monkeypatch):
"""Import auto-creates wrapper scripts for restored profiles."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Mock the wrapper dir to be inside tmp_path
wrapper_dir = tmp_path / ".local" / "bin"
wrapper_dir.mkdir(parents=True)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model:\n provider: openrouter\n",
"profiles/coder/config.yaml": "model:\n provider: anthropic\n",
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-test\n",
"profiles/researcher/config.yaml": "model:\n provider: deepseek\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# Profile directories should exist
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
assert (hermes_home / "profiles" / "researcher" / "config.yaml").exists()
# Wrapper scripts should be created
assert (wrapper_dir / "coder").exists()
assert (wrapper_dir / "researcher").exists()
# Wrappers should contain the right content
coder_wrapper = (wrapper_dir / "coder").read_text()
assert "hermes -p coder" in coder_wrapper
def test_import_skips_profile_dirs_without_config(self, tmp_path, monkeypatch):
"""Import doesn't create wrappers for profile dirs without config."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
wrapper_dir = tmp_path / ".local" / "bin"
wrapper_dir.mkdir(parents=True)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"profiles/valid/config.yaml": "model: test\n",
"profiles/empty/readme.txt": "nothing here\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# Only valid profile should get a wrapper
assert (wrapper_dir / "valid").exists()
assert not (wrapper_dir / "empty").exists()
def test_import_without_profiles_module(self, tmp_path, monkeypatch):
"""Import gracefully handles missing profiles module (fresh install)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"profiles/coder/config.yaml": "model: test\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
# Simulate profiles module not being available
import hermes_cli.backup as backup_mod
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
def fake_import(name, *a, **kw):
if name == "hermes_cli.profiles":
raise ImportError("no profiles module")
return original_import(name, *a, **kw)
from hermes_cli.backup import run_import
with patch("builtins.__import__", side_effect=fake_import):
run_import(args)
# Files should still be restored even if wrappers can't be created
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
+6 -110
View File
@@ -58,13 +58,13 @@ class TestFindOpenclawDirs:
def test_finds_legacy_dirs(self, tmp_path):
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
moldbot = tmp_path / ".moldbot"
moldbot.mkdir()
moltbot = tmp_path / ".moltbot"
moltbot.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert len(found) == 2
assert clawdbot in found
assert moldbot in found
assert moltbot in found
def test_returns_empty_when_none_exist(self, tmp_path):
with patch("pathlib.Path.home", return_value=tmp_path):
@@ -297,7 +297,6 @@ class TestCmdMigrate:
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=True),
patch.object(claw_mod, "_offer_source_archival"),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
@@ -306,43 +305,8 @@ class TestCmdMigrate:
assert "Migration Results" in captured.out
assert "Migration complete!" in captured.out
def test_execute_offers_archival_on_success(self, tmp_path, capsys):
"""After successful migration, _offer_source_archival should be called."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=True,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_called_once_with(openclaw_dir, True)
def test_dry_run_skips_archival(self, tmp_path, capsys):
"""Dry run should not offer archival."""
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
"""Dry run should not modify the source directory."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
@@ -369,11 +333,10 @@ class TestCmdMigrate:
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_not_called()
assert openclaw_dir.is_dir() # Source untouched
def test_execute_cancelled_by_user(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
@@ -506,73 +469,6 @@ class TestCmdMigrate:
assert call_kwargs["migrate_secrets"] is True
# ---------------------------------------------------------------------------
# _offer_source_archival
# ---------------------------------------------------------------------------
class TestOfferSourceArchival:
"""Test the post-migration archival offer."""
def test_archives_with_auto_yes(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "workspace").mkdir()
(source / "workspace" / "todo.json").write_text("{}")
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not source.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert source.is_dir() # Still exists
def test_noop_when_source_missing(self, tmp_path, capsys):
claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True)
captured = capsys.readouterr()
assert captured.out == "" # No output
def test_shows_state_files(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
ws = source / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "todo.json" in captured.out
def test_handles_archive_error(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")):
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Could not archive" in captured.out
# ---------------------------------------------------------------------------
# _cmd_cleanup
# ---------------------------------------------------------------------------
@@ -0,0 +1,241 @@
"""Regression test: openai-codex must appear in /model picker when
credentials are only in the Codex CLI shared file (~/.codex/auth.json)
and haven't been migrated to the Hermes auth store yet.
Root cause: list_authenticated_providers() checked the raw Hermes auth
store but didn't know about the Codex CLI fallback import path.
Fix: _seed_from_singletons() now imports from the Codex CLI when the
Hermes auth store has no openai-codex tokens, and
list_authenticated_providers() falls back to load_pool() for OAuth
providers.
"""
import base64
import json
import os
import sys
import time
from pathlib import Path
from unittest.mock import patch
import pytest
def _make_fake_jwt(expiry_offset: int = 3600) -> str:
"""Build a fake JWT with a future expiry."""
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
exp = int(time.time()) + expiry_offset
payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode()
payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
return f"{header}.{payload}.fakesig"
@pytest.fixture()
def codex_cli_only_env(tmp_path, monkeypatch):
"""Set up an environment where Codex tokens exist only in ~/.codex/auth.json,
NOT in the Hermes auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
codex_home = tmp_path / ".codex"
codex_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
# Empty Hermes auth store
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Valid Codex CLI tokens
fake_jwt = _make_fake_jwt()
(codex_home / "auth.json").write_text(
json.dumps({
"tokens": {
"access_token": fake_jwt,
"refresh_token": "fake-refresh-token",
}
})
)
# Clear provider env vars so only OAuth is a detection path
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env):
"""openai-codex should appear when tokens only exist in ~/.codex/auth.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs, (
f"openai-codex not found in /model picker providers: {slugs}"
)
codex = next(p for p in providers if p["slug"] == "openai-codex")
assert codex["is_current"] is True
assert codex["total_models"] > 0
def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env):
"""After the /model picker detects Codex CLI tokens, they should be
migrated into the Hermes auth store for subsequent fast lookups."""
from hermes_cli.model_switch import list_authenticated_providers
# First call triggers migration
list_authenticated_providers(current_provider="openai-codex")
# Verify tokens are now in Hermes auth store
auth_path = codex_cli_only_env / "auth.json"
store = json.loads(auth_path.read_text())
providers = store.get("providers", {})
assert "openai-codex" in providers, (
f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}"
)
tokens = providers["openai-codex"].get("tokens", {})
assert tokens.get("access_token"), "access_token missing after migration"
assert tokens.get("refresh_token"), "refresh_token missing after migration"
@pytest.fixture()
def hermes_auth_only_env(tmp_path, monkeypatch):
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Point CODEX_HOME to nonexistent dir to prove it's not needed
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 2,
"providers": {
"openai-codex": {
"tokens": {
"access_token": _make_fake_jwt(),
"refresh_token": "fake-refresh",
},
"last_refresh": "2026-04-12T00:00:00Z",
}
},
}))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_normal_path_still_works(hermes_auth_only_env):
"""openai-codex appears when tokens are already in Hermes auth store."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs
@pytest.fixture()
def claude_code_only_env(tmp_path, monkeypatch):
"""Set up an environment where Anthropic credentials only exist in
~/.claude/.credentials.json (Claude Code) not in env vars or Hermes
auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No Codex CLI
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Claude Code credentials in the correct format
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
(claude_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {
"accessToken": _make_fake_jwt(),
"refreshToken": "fake-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
}))
# Patch Path.home() so the adapter finds the file
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_claude_code_file_detected_by_model_picker(claude_code_only_env):
"""anthropic should appear when credentials only exist in ~/.claude/.credentials.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="anthropic",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "anthropic" in slugs, (
f"anthropic not found in /model picker providers: {slugs}"
)
anthropic = next(p for p in providers if p["slug"] == "anthropic")
assert anthropic["is_current"] is True
assert anthropic["total_models"] > 0
def test_no_codex_when_no_credentials(tmp_path, monkeypatch):
"""openai-codex should NOT appear when no credentials exist anywhere."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openrouter",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" not in slugs, (
"openai-codex should not appear without any credentials"
)
+8 -47
View File
@@ -12,49 +12,10 @@ from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.config import (
_is_inside_container,
get_container_exec_info,
)
# =============================================================================
# _is_inside_container
# =============================================================================
def test_is_inside_container_dockerenv():
"""Detects /.dockerenv marker file."""
with patch("os.path.exists") as mock_exists:
mock_exists.side_effect = lambda p: p == "/.dockerenv"
assert _is_inside_container() is True
def test_is_inside_container_containerenv():
"""Detects Podman's /run/.containerenv marker."""
with patch("os.path.exists") as mock_exists:
mock_exists.side_effect = lambda p: p == "/run/.containerenv"
assert _is_inside_container() is True
def test_is_inside_container_cgroup_docker():
"""Detects 'docker' in /proc/1/cgroup."""
with patch("os.path.exists", return_value=False), \
patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = MagicMock(return_value=False)
mock_open.return_value.read = MagicMock(
return_value="12:memory:/docker/abc123\n"
)
assert _is_inside_container() is True
def test_is_inside_container_false_on_host():
"""Returns False when none of the container indicators are present."""
with patch("os.path.exists", return_value=False), \
patch("builtins.open", side_effect=OSError("no such file")):
assert _is_inside_container() is False
# =============================================================================
# get_container_exec_info
# =============================================================================
@@ -81,7 +42,7 @@ def container_env(tmp_path, monkeypatch):
def test_get_container_exec_info_returns_metadata(container_env):
"""Reads .container-mode and returns all fields including exec_user."""
with patch("hermes_cli.config._is_inside_container", return_value=False):
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
@@ -93,7 +54,7 @@ def test_get_container_exec_info_returns_metadata(container_env):
def test_get_container_exec_info_none_inside_container(container_env):
"""Returns None when we're already inside a container."""
with patch("hermes_cli.config._is_inside_container", return_value=True):
with patch("hermes_constants.is_container", return_value=True):
info = get_container_exec_info()
assert info is None
@@ -106,7 +67,7 @@ def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_DEV", raising=False)
with patch("hermes_cli.config._is_inside_container", return_value=False):
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
@@ -116,7 +77,7 @@ def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypa
"""Returns None when HERMES_DEV=1 is set (dev mode bypass)."""
monkeypatch.setenv("HERMES_DEV", "1")
with patch("hermes_cli.config._is_inside_container", return_value=False):
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
@@ -126,7 +87,7 @@ def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env,
"""HERMES_DEV=0 does NOT trigger bypass — only '1' does."""
monkeypatch.setenv("HERMES_DEV", "0")
with patch("hermes_cli.config._is_inside_container", return_value=False):
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
@@ -143,7 +104,7 @@ def test_get_container_exec_info_defaults():
"# minimal file with no keys\n"
)
with patch("hermes_cli.config._is_inside_container", return_value=False), \
with patch("hermes_constants.is_container", return_value=False), \
patch("hermes_cli.config.get_hermes_home", return_value=hermes_home), \
patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_DEV", None)
@@ -165,7 +126,7 @@ def test_get_container_exec_info_docker_backend(container_env):
"hermes_bin=/opt/hermes/bin/hermes\n"
)
with patch("hermes_cli.config._is_inside_container", return_value=False):
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info["backend"] == "docker"
@@ -176,7 +137,7 @@ def test_get_container_exec_info_docker_backend(container_env):
def test_get_container_exec_info_crashes_on_permission_error(container_env):
"""PermissionError propagates instead of being silently swallowed."""
with patch("hermes_cli.config._is_inside_container", return_value=False), \
with patch("hermes_constants.is_container", return_value=False), \
patch("builtins.open", side_effect=PermissionError("permission denied")):
with pytest.raises(PermissionError):
get_container_exec_info()
@@ -122,3 +122,54 @@ class TestCustomProviderModelSwitch:
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-X"
def test_api_mode_set_from_provider_info(self, config_home):
"""When custom_providers entry has api_mode, it should be applied."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Anthropic Proxy",
"base_url": "https://proxy.example.com/anthropic",
"api_key": "***",
"model": "claude-3",
"api_mode": "anthropic_messages",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model.get("api_mode") == "anthropic_messages"
def test_api_mode_cleared_when_not_specified(self, config_home):
"""When custom_providers entry has no api_mode, stale api_mode is removed."""
import yaml
from hermes_cli.main import _model_flow_named_custom
# Pre-seed a stale api_mode in config
config_path = config_home / "config.yaml"
config_path.write_text(yaml.dump({"model": {"api_mode": "anthropic_messages"}}))
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "***",
"model": "llama-3",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert "api_mode" not in model, "Stale api_mode should be removed"
+120
View File
@@ -394,6 +394,21 @@ class TestLaunchdServiceRecovery:
class TestGatewayServiceDetection:
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: None)
assert gateway_cli.supports_systemd_services() is False
def test_supports_systemd_services_returns_true_when_systemctl_present(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: "/usr/bin/systemctl")
assert gateway_cli.supports_systemd_services() is True
def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch):
user_unit = SimpleNamespace(exists=lambda: True)
system_unit = SimpleNamespace(exists=lambda: True)
@@ -418,6 +433,23 @@ class TestGatewayServiceDetection:
assert gateway_cli._is_service_running() is True
def test_is_service_running_returns_false_when_systemctl_missing(self, monkeypatch):
unit = SimpleNamespace(exists=lambda: True)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
monkeypatch.setattr(
gateway_cli,
"get_systemd_unit_path",
lambda system=False: unit,
)
def fake_run(*args, **kwargs):
raise FileNotFoundError("systemctl")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
assert gateway_cli._is_service_running() is False
class TestGatewaySystemServiceRouting:
def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):
@@ -1001,3 +1033,91 @@ class TestSystemUnitPathRemapping:
# Target user paths should be present
assert "/home/alice" in unit
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
class TestDockerAwareGateway:
"""Tests for Docker container awareness in gateway commands."""
def test_run_systemctl_raises_runtimeerror_when_missing(self, monkeypatch):
"""_run_systemctl raises RuntimeError with container guidance when systemctl is absent."""
import pytest
def fake_run(cmd, **kwargs):
raise FileNotFoundError("systemctl")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
with pytest.raises(RuntimeError, match="systemctl is not available"):
gateway_cli._run_systemctl(["start", "hermes-gateway"])
def test_run_systemctl_passes_through_on_success(self, monkeypatch):
"""_run_systemctl delegates to subprocess.run when systemctl exists."""
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
result = gateway_cli._run_systemctl(["status", "hermes-gateway"])
assert result.returncode == 0
assert len(calls) == 1
assert "status" in calls[0]
def test_install_in_container_prints_docker_guidance(self, monkeypatch, capsys):
"""'hermes gateway install' inside Docker exits 0 with container guidance."""
import pytest
monkeypatch.setattr(gateway_cli, "is_managed", lambda: False)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
monkeypatch.setattr(gateway_cli, "is_container", lambda: True)
args = SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None)
with pytest.raises(SystemExit) as exc_info:
gateway_cli.gateway_command(args)
assert exc_info.value.code == 0
out = capsys.readouterr().out
assert "Docker" in out or "docker" in out
assert "restart" in out.lower()
def test_uninstall_in_container_prints_docker_guidance(self, monkeypatch, capsys):
"""'hermes gateway uninstall' inside Docker exits 0 with container guidance."""
import pytest
monkeypatch.setattr(gateway_cli, "is_managed", lambda: False)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_container", lambda: True)
args = SimpleNamespace(gateway_command="uninstall", system=False)
with pytest.raises(SystemExit) as exc_info:
gateway_cli.gateway_command(args)
assert exc_info.value.code == 0
out = capsys.readouterr().out
assert "docker" in out.lower()
def test_start_in_container_prints_docker_guidance(self, monkeypatch, capsys):
"""'hermes gateway start' inside Docker exits 0 with container guidance."""
import pytest
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
monkeypatch.setattr(gateway_cli, "is_container", lambda: True)
args = SimpleNamespace(gateway_command="start", system=False)
with pytest.raises(SystemExit) as exc_info:
gateway_cli.gateway_command(args)
assert exc_info.value.code == 0
out = capsys.readouterr().out
assert "docker" in out.lower()
assert "hermes gateway run" in out
@@ -257,3 +257,76 @@ class TestProviderPersistsAfterModelSave:
assert model.get("provider") == "opencode-go"
assert model.get("default") == "minimax-m2.5"
assert model.get("api_mode") == "anthropic_messages"
class TestBaseUrlValidation:
"""Reject non-URL values in the base URL prompt (e.g. shell commands)."""
def test_invalid_base_url_rejected(self, config_home, monkeypatch, capsys):
"""Typing a non-URL string should not be saved as the base URL."""
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY.get("zai")
if not pconfig:
pytest.skip("zai not in PROVIDER_REGISTRY")
monkeypatch.setenv("GLM_API_KEY", "test-key")
from hermes_cli.main import _model_flow_api_key_provider
from hermes_cli.config import load_config, get_env_value
# User types a shell command instead of a URL at the base URL prompt
with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \
patch("hermes_cli.auth.deactivate_provider"), \
patch("builtins.input", return_value="nano ~/.hermes/.env"):
_model_flow_api_key_provider(load_config(), "zai", "old-model")
# The garbage value should NOT have been saved
saved = get_env_value("GLM_BASE_URL") or ""
assert not saved or saved.startswith(("http://", "https://")), \
f"Non-URL value was saved as GLM_BASE_URL: {saved}"
captured = capsys.readouterr()
assert "Invalid URL" in captured.out
def test_valid_base_url_accepted(self, config_home, monkeypatch):
"""A proper URL should be saved normally."""
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY.get("zai")
if not pconfig:
pytest.skip("zai not in PROVIDER_REGISTRY")
monkeypatch.setenv("GLM_API_KEY", "test-key")
from hermes_cli.main import _model_flow_api_key_provider
from hermes_cli.config import load_config, get_env_value
with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \
patch("hermes_cli.auth.deactivate_provider"), \
patch("builtins.input", return_value="https://custom.z.ai/api/paas/v4"):
_model_flow_api_key_provider(load_config(), "zai", "old-model")
saved = get_env_value("GLM_BASE_URL") or ""
assert saved == "https://custom.z.ai/api/paas/v4"
def test_empty_base_url_keeps_default(self, config_home, monkeypatch):
"""Pressing Enter (empty) should not change the base URL."""
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY.get("zai")
if not pconfig:
pytest.skip("zai not in PROVIDER_REGISTRY")
monkeypatch.setenv("GLM_API_KEY", "test-key")
monkeypatch.delenv("GLM_BASE_URL", raising=False)
from hermes_cli.main import _model_flow_api_key_provider
from hermes_cli.config import load_config, get_env_value
with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \
patch("hermes_cli.auth.deactivate_provider"), \
patch("builtins.input", return_value=""):
_model_flow_api_key_provider(load_config(), "zai", "old-model")
saved = get_env_value("GLM_BASE_URL") or ""
assert saved == "", "Empty input should not save a base URL"
+81 -2
View File
@@ -1,5 +1,4 @@
"""Tests for setup_model_provider — verifies the delegation to
select_provider_and_model() and config dict sync."""
"""Tests for setup.py configuration flows."""
import json
import sys
import types
@@ -8,6 +7,7 @@ import pytest
from hermes_cli.auth import get_active_provider
from hermes_cli.config import load_config, save_config
from hermes_cli import setup as setup_mod
from hermes_cli.setup import setup_model_provider
@@ -144,6 +144,85 @@ def test_setup_custom_providers_synced(tmp_path, monkeypatch):
assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, capsys):
env = {
"TELEGRAM_BOT_TOKEN": "",
"TELEGRAM_HOME_CHANNEL": "",
"DISCORD_BOT_TOKEN": "",
"DISCORD_HOME_CHANNEL": "",
"SLACK_BOT_TOKEN": "",
"SLACK_HOME_CHANNEL": "",
"MATRIX_HOMESERVER": "https://matrix.example.com",
"MATRIX_USER_ID": "@alice:example.com",
"MATRIX_PASSWORD": "",
"MATRIX_ACCESS_TOKEN": "token",
"BLUEBUBBLES_SERVER_URL": "",
"BLUEBUBBLES_HOME_CHANNEL": "",
"WHATSAPP_ENABLED": "",
"WEBHOOK_ENABLED": "",
}
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("platform.system", lambda: "Linux")
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
setup_mod.setup_gateway({})
out = capsys.readouterr().out
assert "Messaging platforms configured!" in out
assert "Start the gateway to bring your bots online:" in out
assert "hermes gateway" in out
def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):
"""setup_gateway() in a Docker container shows Docker-specific restart instructions."""
env = {
"TELEGRAM_BOT_TOKEN": "",
"TELEGRAM_HOME_CHANNEL": "",
"DISCORD_BOT_TOKEN": "",
"DISCORD_HOME_CHANNEL": "",
"SLACK_BOT_TOKEN": "",
"SLACK_HOME_CHANNEL": "",
"MATRIX_HOMESERVER": "https://matrix.example.com",
"MATRIX_USER_ID": "@alice:example.com",
"MATRIX_PASSWORD": "",
"MATRIX_ACCESS_TOKEN": "token",
"BLUEBUBBLES_SERVER_URL": "",
"BLUEBUBBLES_HOME_CHANNEL": "",
"WHATSAPP_ENABLED": "",
"WEBHOOK_ENABLED": "",
}
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("platform.system", lambda: "Linux")
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
# Patch is_container at the import location in setup.py
import hermes_constants
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
setup_mod.setup_gateway({})
out = capsys.readouterr().out
assert "Messaging platforms configured!" in out
assert "docker" in out.lower() or "Docker" in out
assert "restart" in out.lower()
def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):
"""Removing the last custom provider in model setup should persist."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+77
View File
@@ -0,0 +1,77 @@
"""Tests for hermes_cli/tips.py — random tip display at session start."""
import pytest
from hermes_cli.tips import TIPS, get_random_tip, get_tip_count
class TestTipsCorpus:
"""Validate the tip corpus itself."""
def test_has_at_least_200_tips(self):
assert len(TIPS) >= 200, f"Expected 200+ tips, got {len(TIPS)}"
def test_no_duplicates(self):
assert len(TIPS) == len(set(TIPS)), "Duplicate tips found"
def test_all_tips_are_strings(self):
for i, tip in enumerate(TIPS):
assert isinstance(tip, str), f"Tip {i} is not a string: {type(tip)}"
def test_no_empty_tips(self):
for i, tip in enumerate(TIPS):
assert tip.strip(), f"Tip {i} is empty or whitespace-only"
def test_max_length_reasonable(self):
"""Tips should fit on a single terminal line (~120 chars max)."""
for i, tip in enumerate(TIPS):
assert len(tip) <= 150, (
f"Tip {i} too long ({len(tip)} chars): {tip[:60]}..."
)
def test_no_leading_trailing_whitespace(self):
for i, tip in enumerate(TIPS):
assert tip == tip.strip(), f"Tip {i} has leading/trailing whitespace"
class TestGetRandomTip:
"""Validate the get_random_tip() function."""
def test_returns_string(self):
tip = get_random_tip()
assert isinstance(tip, str)
assert len(tip) > 0
def test_returns_tip_from_corpus(self):
tip = get_random_tip()
assert tip in TIPS
def test_randomness(self):
"""Multiple calls should eventually return different tips."""
seen = set()
for _ in range(50):
seen.add(get_random_tip())
# With 200+ tips and 50 draws, we should see at least 10 unique
assert len(seen) >= 10, f"Only got {len(seen)} unique tips in 50 draws"
class TestGetTipCount:
def test_matches_corpus_length(self):
assert get_tip_count() == len(TIPS)
class TestTipIntegrationInCLI:
"""Test that the tip display code in cli.py works correctly."""
def test_tip_import_works(self):
"""The import used in cli.py must succeed."""
from hermes_cli.tips import get_random_tip
assert callable(get_random_tip)
def test_tip_display_format(self):
"""Verify the Rich markup format doesn't break."""
tip = get_random_tip()
color = "#B8860B"
markup = f"[dim {color}]✦ Tip: {tip}[/]"
# Should not contain nested/broken Rich tags
assert markup.count("[/]") == 1
assert "[dim #B8860B]" in markup
@@ -798,3 +798,120 @@ class TestFindGatewayPidsExclude:
pids = gateway_cli.find_gateway_pids()
assert pids == [100]
# ---------------------------------------------------------------------------
# Gateway mode writes exit code before restart (#8300)
# ---------------------------------------------------------------------------
class TestGatewayModeWritesExitCodeEarly:
"""When running as ``hermes update --gateway``, the exit code marker must be
written *before* the gateway restart attempt. Without this, systemd's
``KillMode=mixed`` kills the update process (and its wrapping shell) during
the cgroup teardown, so the shell epilogue that normally writes the exit
code never executes. The new gateway's update watcher then polls for 30
minutes and sends a spurious timeout message.
"""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_exit_code_written_in_gateway_mode(
self, mock_run, _mock_which, capsys, tmp_path, monkeypatch,
):
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
# Point HERMES_HOME at a temp dir so the marker file lands there
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_cli.config as _cfg
monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home)
# Also patch the module-level ref used by cmd_update
import hermes_cli.main as _main_mod
monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home)
mock_run.side_effect = _make_run_side_effect(commit_count="1")
args = SimpleNamespace(gateway=True)
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(args)
exit_code_path = hermes_home / ".update_exit_code"
assert exit_code_path.exists(), ".update_exit_code not written in gateway mode"
assert exit_code_path.read_text() == "0"
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_exit_code_not_written_in_normal_mode(
self, mock_run, _mock_which, capsys, tmp_path, monkeypatch,
):
"""Non-gateway mode should NOT write the exit code (the shell does it)."""
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_cli.config as _cfg
monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home)
import hermes_cli.main as _main_mod
monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home)
mock_run.side_effect = _make_run_side_effect(commit_count="1")
args = SimpleNamespace(gateway=False)
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(args)
exit_code_path = hermes_home / ".update_exit_code"
assert not exit_code_path.exists(), ".update_exit_code should not be written outside gateway mode"
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_exit_code_written_before_restart_call(
self, mock_run, _mock_which, capsys, tmp_path, monkeypatch,
):
"""Exit code must exist BEFORE systemctl restart is called."""
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_cli.config as _cfg
monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home)
import hermes_cli.main as _main_mod
monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home)
exit_code_path = hermes_home / ".update_exit_code"
# Track whether exit code exists when systemctl restart is called
exit_code_existed_at_restart = []
original_side_effect = _make_run_side_effect(
commit_count="1", systemd_active=True,
)
def tracking_side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "systemctl" in joined and "restart" in joined:
exit_code_existed_at_restart.append(exit_code_path.exists())
return original_side_effect(cmd, **kwargs)
mock_run.side_effect = tracking_side_effect
args = SimpleNamespace(gateway=True)
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(args)
assert exit_code_existed_at_restart, "systemctl restart was never called"
assert exit_code_existed_at_restart[0] is True, \
".update_exit_code must exist BEFORE systemctl restart (cgroup kill race)"
@@ -26,6 +26,7 @@ def _make_agent(
agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.api_key = "sk-test"
agent.api_mode = "chat_completions"
agent.quiet_mode = True
agent.log_prefix = ""
agent.compression_enabled = compression_enabled
@@ -99,6 +100,36 @@ def test_no_warning_when_aux_context_sufficient(mock_get_client, mock_ctx_len):
assert agent._compression_warning is None
def test_feasibility_check_passes_live_main_runtime():
"""Compression feasibility should probe using the live session runtime."""
agent = _make_agent(main_context=200_000, threshold_percent=0.50)
agent.model = "gpt-5.4"
agent.provider = "openai-codex"
agent.base_url = "https://chatgpt.com/backend-api/codex"
agent.api_key = "codex-token"
agent.api_mode = "codex_responses"
mock_client = MagicMock()
mock_client.base_url = "https://chatgpt.com/backend-api/codex"
mock_client.api_key = "codex-token"
with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(mock_client, "gpt-5.4")) as mock_get_client, \
patch("agent.model_metadata.get_model_context_length", return_value=200_000):
agent._emit_status = lambda msg: None
agent._check_compression_model_feasibility()
mock_get_client.assert_called_once_with(
"compression",
main_runtime={
"model": "gpt-5.4",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "codex-token",
"api_mode": "codex_responses",
},
)
@patch("agent.auxiliary_client.get_text_auxiliary_client")
def test_warns_when_no_auxiliary_provider(mock_get_client):
"""Warning emitted when no auxiliary provider is configured."""
+127
View File
@@ -185,6 +185,38 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path):
"""MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
# Workspace path is inside the source directory
ws_path = str(source / "workspace")
(source / "credentials").mkdir(parents=True)
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"workspace": ws_path}}}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=True,
output_dir=target / "migration-report",
selected_options={"messaging-settings"},
)
migrator.migrate()
env_path = target / ".env"
if env_path.exists():
assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8")
def test_migrator_can_execute_only_selected_categories(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
@@ -722,3 +754,98 @@ def test_skill_installs_cleanly_under_skills_guard():
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
for f in result.findings:
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
# ── rebrand_text tests ────────────────────────────────────────
def test_rebrand_text_replaces_openclaw_variants():
mod = load_module()
assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11"
assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode"
assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great"
assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely"
assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well"
def test_rebrand_text_replaces_legacy_bot_names():
mod = load_module()
assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone"
assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs"
assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish"
assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python"
def test_rebrand_text_preserves_unrelated_content():
mod = load_module()
text = "User prefers dark mode and lives in Las Vegas"
assert mod.rebrand_text(text) == text
def test_rebrand_text_handles_multiple_replacements():
mod = load_module()
text = "OpenClaw said to ask ClawdBot about MoltBot settings"
assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings"
def test_migrate_memory_rebrands_entries(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
memory_md = workspace / "MEMORY.md"
memory_md.write_text(
"# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n",
encoding="utf-8",
)
target_root = tmp_path / "hermes"
target_root.mkdir()
(target_root / "memories").mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"memory"},
)
migrator.migrate()
result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "ClawdBot" not in result
assert "Hermes" in result
def test_migrate_soul_rebrands_content(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
soul_md = workspace / "SOUL.md"
soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8")
target_root = tmp_path / "hermes"
target_root.mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"soul"},
)
migrator.migrate()
result = (target_root / "SOUL.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "You are Hermes" in result
+120
View File
@@ -0,0 +1,120 @@
"""Tests for empty model fallback — when provider is configured but model is missing."""
from unittest.mock import MagicMock, patch
import pytest
class TestGetDefaultModelForProvider:
"""Unit tests for hermes_cli.models.get_default_model_for_provider."""
def test_known_provider_returns_first_model(self):
from hermes_cli.models import get_default_model_for_provider
result = get_default_model_for_provider("openai-codex")
# Should return first model from _PROVIDER_MODELS["openai-codex"]
assert result
assert isinstance(result, str)
def test_openrouter_returns_empty(self):
"""OpenRouter uses dynamic model fetch, no static catalog entry."""
from hermes_cli.models import get_default_model_for_provider
# OpenRouter is not in _PROVIDER_MODELS — it uses live fetching
result = get_default_model_for_provider("openrouter")
assert result == ""
def test_unknown_provider_returns_empty(self):
from hermes_cli.models import get_default_model_for_provider
assert get_default_model_for_provider("nonexistent-provider") == ""
def test_custom_provider_returns_empty(self):
"""Custom provider has no model catalog — should return empty."""
from hermes_cli.models import get_default_model_for_provider
# Custom providers don't have entries in _PROVIDER_MODELS
assert get_default_model_for_provider("some-random-custom") == ""
class TestGatewayEmptyModelFallback:
"""Test that _resolve_session_agent_runtime fills in empty model from provider catalog."""
def test_empty_model_filled_from_provider(self):
"""When config has no model but provider is openai-codex, use first codex model."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
# Mock _resolve_gateway_model to return empty string
# Mock _resolve_runtime_agent_kwargs to return openai-codex provider
with patch("gateway.run._resolve_gateway_model", return_value=""), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "openai-codex",
"api_key": "test-key",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_mode": "codex_responses",
}):
model, kwargs = runner._resolve_session_agent_runtime()
# Model should have been filled in from provider catalog
assert model, "Model should not be empty when provider is known"
assert isinstance(model, str)
assert kwargs["provider"] == "openai-codex"
def test_nonempty_model_not_overridden(self):
"""When config has a model set, don't override it."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
with patch("gateway.run._resolve_gateway_model", return_value="gpt-5.4"), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "openai-codex",
"api_key": "test-key",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_mode": "codex_responses",
}):
model, kwargs = runner._resolve_session_agent_runtime()
assert model == "gpt-5.4", "Explicit model should not be overridden"
def test_empty_model_no_provider_stays_empty(self):
"""When both model and provider are empty, model stays empty."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
with patch("gateway.run._resolve_gateway_model", return_value=""), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "",
"api_key": "test-key",
"base_url": "https://example.com",
"api_mode": "chat_completions",
}):
model, kwargs = runner._resolve_session_agent_runtime()
# Can't fill in a default without knowing the provider
assert model == ""
class TestResolveGatewayModel:
"""Test _resolve_gateway_model reads model from config correctly."""
def test_returns_default_key(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {"default": "gpt-5.4"}}) == "gpt-5.4"
def test_returns_model_key_fallback(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {"model": "gpt-5.4"}}) == "gpt-5.4"
def test_returns_empty_when_missing(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {}}) == ""
def test_returns_empty_when_no_model_section(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({}) == ""
def test_string_model_config(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": "my-model"}) == "my-model"
+52 -1
View File
@@ -6,7 +6,8 @@ from unittest.mock import patch
import pytest
from hermes_constants import get_default_hermes_root
import hermes_constants
from hermes_constants import get_default_hermes_root, is_container
class TestGetDefaultHermesRoot:
@@ -60,3 +61,53 @@ class TestGetDefaultHermesRoot:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile))
assert get_default_hermes_root() == docker_root
class TestIsContainer:
"""Tests for is_container() — Docker/Podman detection."""
def _reset_cache(self, monkeypatch):
"""Reset the cached detection result before each test."""
monkeypatch.setattr(hermes_constants, "_container_detected", None)
def test_detects_dockerenv(self, monkeypatch, tmp_path):
"""/.dockerenv triggers container detection."""
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: p == "/.dockerenv")
assert is_container() is True
def test_detects_containerenv(self, monkeypatch, tmp_path):
"""/run/.containerenv triggers container detection (Podman)."""
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: p == "/run/.containerenv")
assert is_container() is True
def test_detects_cgroup_docker(self, monkeypatch, tmp_path):
"""/proc/1/cgroup containing 'docker' triggers detection."""
import builtins
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: False)
cgroup_file = tmp_path / "cgroup"
cgroup_file.write_text("12:memory:/docker/abc123\n")
_real_open = builtins.open
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
assert is_container() is True
def test_negative_case(self, monkeypatch, tmp_path):
"""Returns False on a regular Linux host."""
import builtins
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: False)
cgroup_file = tmp_path / "cgroup"
cgroup_file.write_text("12:memory:/\n")
_real_open = builtins.open
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
assert is_container() is False
def test_caches_result(self, monkeypatch):
"""Second call uses cached value without re-probing."""
monkeypatch.setattr(hermes_constants, "_container_detected", True)
assert is_container() is True
# Even if we make os.path.exists return False, cached value wins
monkeypatch.setattr(os.path, "exists", lambda p: False)
assert is_container() is True
+114
View File
@@ -0,0 +1,114 @@
"""Tests for network.force_ipv4 — the socket.getaddrinfo monkey-patch."""
import importlib
import socket
from unittest.mock import patch, MagicMock
import pytest
def _reload_constants():
"""Reload hermes_constants to get a fresh apply_ipv4_preference."""
import hermes_constants
importlib.reload(hermes_constants)
return hermes_constants
class TestApplyIPv4Preference:
"""Tests for apply_ipv4_preference()."""
def setup_method(self):
"""Save the original getaddrinfo before each test."""
self._original = socket.getaddrinfo
def teardown_method(self):
"""Restore the original getaddrinfo after each test."""
socket.getaddrinfo = self._original
def test_noop_when_force_false(self):
"""No patch when force=False."""
from hermes_constants import apply_ipv4_preference
original = socket.getaddrinfo
apply_ipv4_preference(force=False)
assert socket.getaddrinfo is original
def test_patches_getaddrinfo_when_forced(self):
"""Patches socket.getaddrinfo when force=True."""
from hermes_constants import apply_ipv4_preference
original = socket.getaddrinfo
apply_ipv4_preference(force=True)
assert socket.getaddrinfo is not original
assert getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False) is True
def test_double_patch_is_safe(self):
"""Calling apply twice doesn't double-wrap."""
from hermes_constants import apply_ipv4_preference
apply_ipv4_preference(force=True)
first_patch = socket.getaddrinfo
apply_ipv4_preference(force=True)
assert socket.getaddrinfo is first_patch
def test_af_unspec_becomes_af_inet(self):
"""AF_UNSPEC (default) calls get rewritten to AF_INET."""
from hermes_constants import apply_ipv4_preference
calls = []
original = socket.getaddrinfo
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
calls.append(family)
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
# Call with default family (AF_UNSPEC = 0)
socket.getaddrinfo("example.com", 80)
assert calls[-1] == socket.AF_INET, "AF_UNSPEC should be rewritten to AF_INET"
def test_explicit_family_preserved(self):
"""Explicit AF_INET6 requests are not intercepted."""
from hermes_constants import apply_ipv4_preference
calls = []
original = socket.getaddrinfo
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
calls.append(family)
return [(family, socket.SOCK_STREAM, 6, "", ("::1", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
socket.getaddrinfo("example.com", 80, family=socket.AF_INET6)
assert calls[-1] == socket.AF_INET6, "Explicit AF_INET6 should pass through"
def test_fallback_on_gaierror(self):
"""Falls back to AF_UNSPEC if AF_INET resolution fails."""
from hermes_constants import apply_ipv4_preference
call_families = []
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
call_families.append(family)
if family == socket.AF_INET:
raise socket.gaierror("No A record")
# AF_UNSPEC fallback returns IPv6
return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
result = socket.getaddrinfo("ipv6only.example.com", 80)
# Should have tried AF_INET first, then fallen back to AF_UNSPEC
assert call_families == [socket.AF_INET, 0]
assert result[0][0] == socket.AF_INET6
class TestConfigDefault:
"""Verify network section exists in DEFAULT_CONFIG."""
def test_network_section_in_default_config(self):
from hermes_cli.config import DEFAULT_CONFIG
assert "network" in DEFAULT_CONFIG
assert DEFAULT_CONFIG["network"]["force_ipv4"] is False
-306
View File
@@ -1,306 +0,0 @@
"""Tests for FileSyncManager.sync_back() — pull remote changes to host."""
import fcntl
import io
import logging
import os
import signal
import tarfile
import time
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
from tools.environments.file_sync import (
FileSyncManager,
_sha256_file,
_SYNC_BACK_BACKOFF,
_SYNC_BACK_MAX_RETRIES,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tar(files: dict[str, bytes], dest: Path):
"""Write a tar archive containing the given arcname->content pairs."""
with tarfile.open(dest, "w") as tar:
for arcname, content in files.items():
info = tarfile.TarInfo(name=arcname)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
def _make_download_fn(files: dict[str, bytes]):
"""Return a bulk_download_fn that writes a tar of the given files."""
def download(dest: Path):
_make_tar(files, dest)
return download
def _sha256_bytes(data: bytes) -> str:
"""Compute SHA-256 hex digest of raw bytes (for test convenience)."""
import hashlib
return hashlib.sha256(data).hexdigest()
def _write_file(path: Path, content: bytes) -> str:
"""Write bytes to *path*, creating parents, and return the string path."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return str(path)
def _make_manager(
tmp_path: Path,
file_mapping: list[tuple[str, str]] | None = None,
bulk_download_fn=None,
) -> FileSyncManager:
"""Create a FileSyncManager wired for testing.
*file_mapping* is a list of (host_path, remote_path) tuples that
``get_files_fn`` returns. If *None* an empty list is used.
"""
mapping = file_mapping or []
return FileSyncManager(
get_files_fn=lambda: mapping,
upload_fn=MagicMock(),
delete_fn=MagicMock(),
bulk_download_fn=bulk_download_fn,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSyncBackNoop:
"""sync_back() is a no-op when there is no download function."""
def test_sync_back_noop_without_download_fn(self, tmp_path):
mgr = _make_manager(tmp_path, bulk_download_fn=None)
# Should return immediately without error
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# Nothing to assert beyond "no exception raised"
class TestSyncBackNoChanges:
"""When all remote files match pushed hashes, nothing is applied."""
def test_sync_back_no_changes(self, tmp_path):
host_file = tmp_path / "host" / "cred.json"
host_content = b'{"key": "val"}'
_write_file(host_file, host_content)
remote_path = "/root/.hermes/cred.json"
mapping = [(str(host_file), remote_path)]
# Remote tar contains the same content as was pushed
download_fn = _make_download_fn({
"root/.hermes/cred.json": host_content,
})
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
# Simulate that we already pushed this file with this hash
mgr._pushed_hashes[remote_path] = _sha256_bytes(host_content)
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# Host file should be unchanged (same content, same bytes)
assert host_file.read_bytes() == host_content
class TestSyncBackAppliesChanged:
"""Remote file differs from pushed version -- gets copied to host."""
def test_sync_back_applies_changed_file(self, tmp_path):
host_file = tmp_path / "host" / "skill.py"
original_content = b"print('v1')"
_write_file(host_file, original_content)
remote_path = "/root/.hermes/skill.py"
mapping = [(str(host_file), remote_path)]
remote_content = b"print('v2 - edited on remote')"
download_fn = _make_download_fn({
"root/.hermes/skill.py": remote_content,
})
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content)
mgr.sync_back(hermes_home=tmp_path / ".hermes")
assert host_file.read_bytes() == remote_content
class TestSyncBackNewRemoteFile:
"""File created on remote (not in _pushed_hashes) is applied via _infer_host_path."""
def test_sync_back_detects_new_remote_file(self, tmp_path):
# Existing mapping gives _infer_host_path a prefix to work with
existing_host = tmp_path / "host" / "skills" / "existing.py"
_write_file(existing_host, b"existing")
mapping = [(str(existing_host), "/root/.hermes/skills/existing.py")]
# Remote has a NEW file in the same directory that was never pushed
new_remote_content = b"# brand new skill created on remote"
download_fn = _make_download_fn({
"root/.hermes/skills/new_skill.py": new_remote_content,
})
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
# No entry in _pushed_hashes for the new file
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# The new file should have been inferred and written to the host
expected_host_path = tmp_path / "host" / "skills" / "new_skill.py"
assert expected_host_path.exists()
assert expected_host_path.read_bytes() == new_remote_content
class TestSyncBackConflict:
"""Host AND remote both changed since push -- warning logged, remote wins."""
def test_sync_back_conflict_warns(self, tmp_path, caplog):
host_file = tmp_path / "host" / "config.json"
original_content = b'{"v": 1}'
_write_file(host_file, original_content)
remote_path = "/root/.hermes/config.json"
mapping = [(str(host_file), remote_path)]
# Host was modified after push
host_file.write_bytes(b'{"v": 2, "host-edit": true}')
# Remote was also modified
remote_content = b'{"v": 3, "remote-edit": true}'
download_fn = _make_download_fn({
"root/.hermes/config.json": remote_content,
})
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content)
with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"):
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# Conflict warning was logged
assert any("conflict" in r.message.lower() for r in caplog.records)
# Remote version wins (last-write-wins)
assert host_file.read_bytes() == remote_content
class TestSyncBackRetries:
"""Retry behaviour with exponential backoff."""
@patch("tools.environments.file_sync.time.sleep")
def test_sync_back_retries_on_failure(self, mock_sleep, tmp_path):
call_count = 0
def flaky_download(dest: Path):
nonlocal call_count
call_count += 1
if call_count < 3:
raise RuntimeError(f"network error #{call_count}")
# Third attempt succeeds -- write a valid (empty) tar
_make_tar({}, dest)
mgr = _make_manager(tmp_path, bulk_download_fn=flaky_download)
mgr.sync_back(hermes_home=tmp_path / ".hermes")
assert call_count == 3
# Sleep called twice (between attempt 1->2 and 2->3)
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[0])
mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[1])
@patch("tools.environments.file_sync.time.sleep")
def test_sync_back_all_retries_exhausted(self, mock_sleep, tmp_path, caplog):
def always_fail(dest: Path):
raise RuntimeError("persistent failure")
mgr = _make_manager(tmp_path, bulk_download_fn=always_fail)
with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"):
# Should NOT raise -- failures are logged, not propagated
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# All retries were attempted
assert mock_sleep.call_count == _SYNC_BACK_MAX_RETRIES - 1
# Final "all attempts failed" warning was logged
assert any("all" in r.message.lower() and "failed" in r.message.lower() for r in caplog.records)
class TestPushedHashesPopulated:
"""_pushed_hashes is populated during sync() and cleared on delete."""
def test_pushed_hashes_populated_on_sync(self, tmp_path):
host_file = tmp_path / "data.txt"
host_file.write_bytes(b"hello world")
remote_path = "/root/.hermes/data.txt"
mapping = [(str(host_file), remote_path)]
mgr = FileSyncManager(
get_files_fn=lambda: mapping,
upload_fn=MagicMock(),
delete_fn=MagicMock(),
)
mgr.sync(force=True)
assert remote_path in mgr._pushed_hashes
assert mgr._pushed_hashes[remote_path] == _sha256_file(str(host_file))
def test_pushed_hashes_cleared_on_delete(self, tmp_path):
host_file = tmp_path / "deleteme.txt"
host_file.write_bytes(b"to be deleted")
remote_path = "/root/.hermes/deleteme.txt"
mapping = [(str(host_file), remote_path)]
current_mapping = list(mapping)
mgr = FileSyncManager(
get_files_fn=lambda: current_mapping,
upload_fn=MagicMock(),
delete_fn=MagicMock(),
)
# Sync to populate hashes
mgr.sync(force=True)
assert remote_path in mgr._pushed_hashes
# Remove the file from the mapping (simulates local deletion)
os.unlink(str(host_file))
current_mapping.clear()
mgr.sync(force=True)
# Hash should be cleaned up
assert remote_path not in mgr._pushed_hashes
class TestSyncBackFileLock:
"""Verify that fcntl.flock is used during sync-back."""
@patch("tools.environments.file_sync.fcntl.flock")
def test_sync_back_file_lock(self, mock_flock, tmp_path):
download_fn = _make_download_fn({})
mgr = _make_manager(tmp_path, bulk_download_fn=download_fn)
mgr.sync_back(hermes_home=tmp_path / ".hermes")
# flock should have been called at least twice: LOCK_EX to acquire, LOCK_UN to release
assert mock_flock.call_count >= 2
lock_calls = mock_flock.call_args_list
lock_ops = [c[0][1] for c in lock_calls]
assert fcntl.LOCK_EX in lock_ops
assert fcntl.LOCK_UN in lock_ops
+59
View File
@@ -289,3 +289,62 @@ class TestCodeExecutionBlocked:
def test_notify_on_complete_blocked_in_sandbox(self):
from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS
assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS
# =========================================================================
# Completion consumed suppression
# =========================================================================
class TestCompletionConsumed:
"""Test that wait/poll/log suppress redundant completion notifications."""
def test_wait_marks_completion_consumed(self, registry):
"""wait() returning exited status marks session as consumed."""
s = _make_session(sid="proc_wait", notify_on_complete=True, output="done")
s.exited = True
s.exit_code = 0
registry._running[s.id] = s
with patch.object(registry, "_write_checkpoint"):
registry._move_to_finished(s)
# Notification is in the queue
assert not registry.completion_queue.empty()
assert not registry.is_completion_consumed("proc_wait")
# Agent calls wait() — gets the result directly
result = registry.wait("proc_wait", timeout=1)
assert result["status"] == "exited"
# Now the completion is marked as consumed
assert registry.is_completion_consumed("proc_wait")
def test_poll_marks_completion_consumed(self, registry):
"""poll() returning exited status marks session as consumed."""
s = _make_session(sid="proc_poll", notify_on_complete=True, output="done")
s.exited = True
s.exit_code = 0
registry._finished[s.id] = s
result = registry.poll("proc_poll")
assert result["status"] == "exited"
assert registry.is_completion_consumed("proc_poll")
def test_log_marks_completion_consumed(self, registry):
"""read_log() on exited session marks as consumed."""
s = _make_session(sid="proc_log", notify_on_complete=True, output="line1\nline2")
s.exited = True
s.exit_code = 0
registry._finished[s.id] = s
result = registry.read_log("proc_log")
assert result["status"] == "exited"
assert registry.is_completion_consumed("proc_log")
def test_running_process_not_consumed(self, registry):
"""poll() on a still-running process does not mark as consumed."""
s = _make_session(sid="proc_running", notify_on_complete=True, output="partial")
registry._running[s.id] = s
result = registry.poll("proc_running")
assert result["status"] == "running"
assert not registry.is_completion_consumed("proc_running")
-485
View File
@@ -1,485 +0,0 @@
"""Tests for backend-specific bulk download implementations and cleanup() wiring."""
import asyncio
import subprocess
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from tools.environments import ssh as ssh_env
from tools.environments import modal as modal_env
from tools.environments import daytona as daytona_env
from tools.environments.ssh import SSHEnvironment
# ── SSH helpers ──────────────────────────────────────────────────────
@pytest.fixture
def ssh_mock_env(monkeypatch):
"""Create an SSHEnvironment with mocked connection/sync."""
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/home/testuser")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
monkeypatch.setattr(
ssh_env, "FileSyncManager",
lambda **kw: type("M", (), {
"sync": lambda self, **k: None,
"sync_back": lambda self: None,
})(),
)
return SSHEnvironment(host="example.com", user="testuser")
# ── Modal helpers ────────────────────────────────────────────────────
def _make_mock_modal_env():
"""Create a minimal ModalEnvironment without calling __init__."""
env = object.__new__(modal_env.ModalEnvironment)
env._sandbox = MagicMock()
env._worker = MagicMock()
env._persistent = False
env._task_id = "test"
env._sync_manager = None
return env
def _wire_modal_download(env, *, tar_bytes=b"fake-tar-data", exit_code=0):
"""Wire sandbox.exec.aio to return mock tar output for download tests.
Returns the exec_calls list for assertion.
"""
exec_calls = []
async def mock_exec_fn(*args, **kwargs):
exec_calls.append(args)
proc = MagicMock()
proc.stdout = MagicMock()
proc.stdout.read = MagicMock()
proc.stdout.read.aio = AsyncMock(return_value=tar_bytes)
proc.wait = MagicMock()
proc.wait.aio = AsyncMock(return_value=exit_code)
return proc
env._sandbox.exec = MagicMock()
env._sandbox.exec.aio = mock_exec_fn
def real_run_coroutine(coro, **kwargs):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
env._worker.run_coroutine = real_run_coroutine
return exec_calls
# ── Daytona helpers ──────────────────────────────────────────────────
def _make_mock_daytona_env():
"""Create a minimal DaytonaEnvironment without calling __init__."""
env = object.__new__(daytona_env.DaytonaEnvironment)
env._sandbox = MagicMock()
env._remote_home = "/root"
env._sync_manager = None
env._lock = __import__("threading").Lock()
env._persistent = True
env._task_id = "test"
env._daytona = MagicMock()
return env
# =====================================================================
# SSH bulk download
# =====================================================================
class TestSSHBulkDownload:
"""Unit tests for _ssh_bulk_download."""
def test_ssh_bulk_download_runs_tar_over_ssh(self, ssh_mock_env, tmp_path):
"""subprocess.run command should include tar cf - over SSH."""
dest = tmp_path / "backup.tar"
with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as mock_run:
# open() will be called to write stdout; mock it to avoid actual file I/O
ssh_mock_env._ssh_bulk_download(dest)
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
assert "tar cf -" in cmd_str
assert "-C /" in cmd_str
assert "home/testuser/.hermes" in cmd_str
assert "ssh" in cmd_str
assert "testuser@example.com" in cmd_str
def test_ssh_bulk_download_writes_to_dest(self, ssh_mock_env, tmp_path):
"""subprocess.run should receive stdout=open(dest, 'wb')."""
dest = tmp_path / "backup.tar"
with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as mock_run:
ssh_mock_env._ssh_bulk_download(dest)
# The stdout kwarg should be a file object opened for writing
call_kwargs = mock_run.call_args
# stdout is passed as a keyword arg
stdout_val = call_kwargs.kwargs.get("stdout") or call_kwargs[1].get("stdout")
# The file was opened via `with open(dest, "wb") as f` and passed as stdout=f.
# After the context manager exits, the file is closed, but we can verify
# the dest path was used by checking if the file was created.
assert dest.exists()
def test_ssh_bulk_download_raises_on_failure(self, ssh_mock_env, tmp_path):
"""Non-zero returncode should raise RuntimeError."""
dest = tmp_path / "backup.tar"
failed = subprocess.CompletedProcess([], 1, stderr=b"Permission denied")
with patch.object(subprocess, "run", return_value=failed):
with pytest.raises(RuntimeError, match="SSH bulk download failed"):
ssh_mock_env._ssh_bulk_download(dest)
def test_ssh_bulk_download_uses_120s_timeout(self, ssh_mock_env, tmp_path):
"""The subprocess.run call should use a 120s timeout."""
dest = tmp_path / "backup.tar"
with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as mock_run:
ssh_mock_env._ssh_bulk_download(dest)
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs.get("timeout") == 120 or call_kwargs[1].get("timeout") == 120
class TestSSHCleanup:
"""Verify SSH cleanup() calls sync_back() before closing ControlMaster."""
def test_ssh_cleanup_calls_sync_back(self, monkeypatch):
"""cleanup() should call sync_back() before SSH control socket teardown."""
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/home/u")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
call_order = []
class TrackingSyncManager:
def __init__(self, **kwargs):
pass
def sync(self, **kw):
pass
def sync_back(self):
call_order.append("sync_back")
monkeypatch.setattr(ssh_env, "FileSyncManager", TrackingSyncManager)
env = SSHEnvironment(host="h", user="u")
# Ensure control_socket does not exist so cleanup skips the SSH exit call
env.control_socket = Path("/nonexistent/socket")
env.cleanup()
assert "sync_back" in call_order
def test_ssh_cleanup_calls_sync_back_before_control_exit(self, monkeypatch):
"""sync_back() must run before the ControlMaster exit command."""
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/home/u")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
call_order = []
class TrackingSyncManager:
def __init__(self, **kwargs):
pass
def sync(self, **kw):
pass
def sync_back(self):
call_order.append("sync_back")
monkeypatch.setattr(ssh_env, "FileSyncManager", TrackingSyncManager)
env = SSHEnvironment(host="h", user="u")
# Create a fake control socket so cleanup tries the SSH exit
import tempfile
with tempfile.NamedTemporaryFile(delete=False, suffix=".sock") as tmp:
env.control_socket = Path(tmp.name)
def mock_run(cmd, **kwargs):
cmd_str = " ".join(cmd)
if "-O" in cmd and "exit" in cmd_str:
call_order.append("control_exit")
return subprocess.CompletedProcess([], 0)
with patch.object(subprocess, "run", side_effect=mock_run):
env.cleanup()
assert call_order.index("sync_back") < call_order.index("control_exit")
# =====================================================================
# Modal bulk download
# =====================================================================
class TestModalBulkDownload:
"""Unit tests for _modal_bulk_download."""
def test_modal_bulk_download_command(self, tmp_path):
"""exec should be called with tar cf - -C /root/.hermes ."""
env = _make_mock_modal_env()
exec_calls = _wire_modal_download(env, tar_bytes=b"tar-content")
dest = tmp_path / "backup.tar"
env._modal_bulk_download(dest)
assert len(exec_calls) == 1
args = exec_calls[0]
assert args[0] == "bash"
assert args[1] == "-c"
assert "tar cf -" in args[2]
assert "-C / root/.hermes" in args[2]
def test_modal_bulk_download_writes_to_dest(self, tmp_path):
"""Downloaded tar bytes should be written to the dest path."""
env = _make_mock_modal_env()
expected_data = b"some-tar-archive-bytes"
_wire_modal_download(env, tar_bytes=expected_data)
dest = tmp_path / "backup.tar"
env._modal_bulk_download(dest)
assert dest.exists()
assert dest.read_bytes() == expected_data
def test_modal_bulk_download_handles_str_output(self, tmp_path):
"""If stdout returns str instead of bytes, it should be encoded."""
env = _make_mock_modal_env()
# Simulate Modal SDK returning str
_wire_modal_download(env, tar_bytes="string-tar-data")
dest = tmp_path / "backup.tar"
env._modal_bulk_download(dest)
assert dest.read_bytes() == b"string-tar-data"
def test_modal_bulk_download_raises_on_failure(self, tmp_path):
"""Non-zero exit code should raise RuntimeError."""
env = _make_mock_modal_env()
_wire_modal_download(env, exit_code=1)
dest = tmp_path / "backup.tar"
with pytest.raises(RuntimeError, match="Modal bulk download failed"):
env._modal_bulk_download(dest)
def test_modal_bulk_download_uses_120s_timeout(self, tmp_path):
"""run_coroutine should be called with timeout=120."""
env = _make_mock_modal_env()
_wire_modal_download(env, tar_bytes=b"data")
run_kwargs = {}
original_run = env._worker.run_coroutine
def tracking_run(coro, **kwargs):
run_kwargs.update(kwargs)
return original_run(coro, **kwargs)
env._worker.run_coroutine = tracking_run
dest = tmp_path / "backup.tar"
env._modal_bulk_download(dest)
assert run_kwargs.get("timeout") == 120
class TestModalCleanup:
"""Verify Modal cleanup() calls sync_back() before terminate."""
def test_modal_cleanup_calls_sync_back(self):
"""cleanup() should call sync_back() before sandbox.terminate."""
env = _make_mock_modal_env()
call_order = []
sync_mgr = MagicMock()
sync_mgr.sync_back = lambda: call_order.append("sync_back")
env._sync_manager = sync_mgr
# Mock terminate to track call order
async def mock_terminate():
pass
env._sandbox.terminate = MagicMock()
env._sandbox.terminate.aio = mock_terminate
env._worker.run_coroutine = lambda coro, **kw: (
call_order.append("terminate"),
asyncio.new_event_loop().run_until_complete(coro),
)
env._worker.stop = lambda: None
env.cleanup()
assert "sync_back" in call_order
assert call_order.index("sync_back") < call_order.index("terminate")
# =====================================================================
# Daytona bulk download
# =====================================================================
class TestDaytonaBulkDownload:
"""Unit tests for _daytona_bulk_download."""
def test_daytona_bulk_download_creates_tar_and_downloads(self, tmp_path):
"""exec and download_file should both be called."""
env = _make_mock_daytona_env()
dest = tmp_path / "backup.tar"
env._daytona_bulk_download(dest)
env._sandbox.process.exec.assert_called_once()
exec_cmd = env._sandbox.process.exec.call_args[0][0]
assert "tar cf" in exec_cmd
assert "/tmp/.hermes_sync.tar" in exec_cmd
assert ".hermes" in exec_cmd
env._sandbox.fs.download_file.assert_called_once_with(
"/tmp/.hermes_sync.tar", str(dest)
)
def test_daytona_bulk_download_uses_remote_home(self, tmp_path):
"""The tar command should use the env's _remote_home."""
env = _make_mock_daytona_env()
env._remote_home = "/home/daytona"
dest = tmp_path / "backup.tar"
env._daytona_bulk_download(dest)
exec_cmd = env._sandbox.process.exec.call_args[0][0]
assert "home/daytona/.hermes" in exec_cmd
class TestDaytonaCleanup:
"""Verify Daytona cleanup() calls sync_back() before stop."""
def test_daytona_cleanup_calls_sync_back(self):
"""cleanup() should call sync_back() before sandbox.stop()."""
env = _make_mock_daytona_env()
call_order = []
sync_mgr = MagicMock()
sync_mgr.sync_back = lambda: call_order.append("sync_back")
env._sync_manager = sync_mgr
env._sandbox.stop = lambda: call_order.append("stop")
env.cleanup()
assert "sync_back" in call_order
assert "stop" in call_order
assert call_order.index("sync_back") < call_order.index("stop")
# =====================================================================
# FileSyncManager wiring: bulk_download_fn passed by each backend
# =====================================================================
class TestBulkDownloadWiring:
"""Verify each backend passes bulk_download_fn to FileSyncManager."""
def test_ssh_passes_bulk_download_fn(self, monkeypatch):
"""SSHEnvironment should pass _ssh_bulk_download to FileSyncManager."""
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/root")
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
captured_kwargs = {}
class CaptureSyncManager:
def __init__(self, **kwargs):
captured_kwargs.update(kwargs)
def sync(self, **kw):
pass
monkeypatch.setattr(ssh_env, "FileSyncManager", CaptureSyncManager)
SSHEnvironment(host="h", user="u")
assert "bulk_download_fn" in captured_kwargs
assert callable(captured_kwargs["bulk_download_fn"])
def test_modal_passes_bulk_download_fn(self, monkeypatch):
"""ModalEnvironment should pass _modal_bulk_download to FileSyncManager."""
captured_kwargs = {}
def capture_fsm(**kwargs):
captured_kwargs.update(kwargs)
return type("M", (), {"sync": lambda self, **k: None})()
monkeypatch.setattr(modal_env, "FileSyncManager", capture_fsm)
env = object.__new__(modal_env.ModalEnvironment)
env._sandbox = MagicMock()
env._worker = MagicMock()
env._persistent = False
env._task_id = "test"
# Replicate the wiring done in __init__
from tools.environments.file_sync import iter_sync_files
env._sync_manager = modal_env.FileSyncManager(
get_files_fn=lambda: iter_sync_files("/root/.hermes"),
upload_fn=env._modal_upload,
delete_fn=env._modal_delete,
bulk_upload_fn=env._modal_bulk_upload,
bulk_download_fn=env._modal_bulk_download,
)
assert "bulk_download_fn" in captured_kwargs
assert callable(captured_kwargs["bulk_download_fn"])
def test_daytona_passes_bulk_download_fn(self, monkeypatch):
"""DaytonaEnvironment should pass _daytona_bulk_download to FileSyncManager."""
captured_kwargs = {}
def capture_fsm(**kwargs):
captured_kwargs.update(kwargs)
return type("M", (), {"sync": lambda self, **k: None})()
monkeypatch.setattr(daytona_env, "FileSyncManager", capture_fsm)
env = object.__new__(daytona_env.DaytonaEnvironment)
env._sandbox = MagicMock()
env._remote_home = "/root"
env._lock = __import__("threading").Lock()
env._persistent = True
env._task_id = "test"
env._daytona = MagicMock()
# Replicate the wiring done in __init__
from tools.environments.file_sync import iter_sync_files
env._sync_manager = daytona_env.FileSyncManager(
get_files_fn=lambda: iter_sync_files(f"{env._remote_home}/.hermes"),
upload_fn=env._daytona_upload,
delete_fn=env._daytona_delete,
bulk_upload_fn=env._daytona_bulk_upload,
bulk_download_fn=env._daytona_bulk_download,
)
assert "bulk_download_fn" in captured_kwargs
assert callable(captured_kwargs["bulk_download_fn"])
+11 -2
View File
@@ -6,12 +6,15 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
"""
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Import from cron module (will be available when properly installed)
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -68,11 +71,17 @@ def _origin_from_env() -> Optional[Dict[str, str]]:
origin_platform = get_session_env("HERMES_SESSION_PLATFORM")
origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if origin_platform and origin_chat_id:
thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None
if thread_id:
logger.debug(
"Cron origin captured thread_id=%s for %s:%s",
thread_id, origin_platform, origin_chat_id,
)
return {
"platform": origin_platform,
"chat_id": origin_chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
"thread_id": thread_id,
}
return None
@@ -456,7 +465,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"deliver": {
"type": "string",
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, wecom_callback, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting."
},
"skills": {
"type": "array",
+18 -3
View File
@@ -25,6 +25,8 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS
# Tools that children must never have access to
DELEGATE_BLOCKED_TOOLS = frozenset([
@@ -35,6 +37,18 @@ DELEGATE_BLOCKED_TOOLS = frozenset([
"execute_code", # children should reason step-by-step, not write scripts
])
# Build a description fragment listing toolsets available for subagents.
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
# (hermes-* prefixed), and scenario toolsets.
_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"})
_SUBAGENT_TOOLSETS = sorted(
name for name, defn in TOOLSETS.items()
if name not in _EXCLUDED_TOOLSET_NAMES
and not name.startswith("hermes-")
and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
)
_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
@@ -999,9 +1013,10 @@ DELEGATE_TASK_SCHEMA = {
"description": (
"Toolsets to enable for this subagent. "
"Default: inherits your enabled toolsets. "
f"Available toolsets: {_TOOLSET_LIST_STR}. "
"Common patterns: ['terminal', 'file'] for code work, "
"['web'] for research, ['terminal', 'file', 'web'] for "
"full-stack tasks."
"['web'] for research, ['browser'] for web interaction, "
"['terminal', 'file', 'web'] for full-stack tasks."
),
},
"tasks": {
@@ -1014,7 +1029,7 @@ DELEGATE_TASK_SCHEMA = {
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.",
"description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.",
},
"acp_command": {
"type": "string",
-14
View File
@@ -16,7 +16,6 @@ from tools.environments.base import (
_ThreadedProcessHandle,
)
from tools.environments.file_sync import (
BulkDownloadFn,
FileSyncManager,
iter_sync_files,
quoted_mkdir_command,
@@ -135,7 +134,6 @@ class DaytonaEnvironment(BaseEnvironment):
upload_fn=self._daytona_upload,
delete_fn=self._daytona_delete,
bulk_upload_fn=self._daytona_bulk_upload,
bulk_download_fn=self._daytona_bulk_download,
)
self._sync_manager.sync(force=True)
self.init_session()
@@ -168,14 +166,6 @@ class DaytonaEnvironment(BaseEnvironment):
]
self._sandbox.fs.upload_files(uploads)
def _daytona_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
rel_base = f"{self._remote_home}/.hermes".lstrip("/")
self._sandbox.process.exec(
f"tar cf /tmp/.hermes_sync.tar -C / {shlex.quote(rel_base)}"
)
self._sandbox.fs.download_file("/tmp/.hermes_sync.tar", str(dest))
def _daytona_delete(self, remote_paths: list[str]) -> None:
"""Batch-delete remote files via SDK exec."""
self._sandbox.process.exec(quoted_rm_command(remote_paths))
@@ -223,10 +213,6 @@ class DaytonaEnvironment(BaseEnvironment):
return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
def cleanup(self):
if self._sync_manager:
logger.info("Daytona: syncing files from sandbox...")
self._sync_manager.sync_back()
with self._lock:
if self._sandbox is None:
return
-189
View File
@@ -6,21 +6,13 @@ and Daytona. Docker and Singularity use bind mounts (live host FS
view) and don't need this.
"""
import fcntl
import hashlib
import logging
import os
import shlex
import shutil
import signal
import tarfile
import tempfile
import threading
import time
from pathlib import Path
from typing import Callable
from hermes_constants import get_hermes_home
from tools.environments.base import _file_mtime_key
logger = logging.getLogger(__name__)
@@ -31,7 +23,6 @@ _FORCE_SYNC_ENV = "HERMES_FORCE_FILE_SYNC"
# Transport callbacks provided by each backend
UploadFn = Callable[[str, str], None] # (host_path, remote_path) -> raises on failure
BulkUploadFn = Callable[[list[tuple[str, str]]], None] # [(host_path, remote_path), ...] -> raises on failure
BulkDownloadFn = Callable[[Path], None] # (dest_tar_path) -> writes tar archive, raises on failure
DeleteFn = Callable[[list[str]], None] # (remote_paths) -> raises on failure
GetFilesFn = Callable[[], list[tuple[str, str]]] # () -> [(host_path, remote_path), ...]
@@ -80,19 +71,6 @@ def unique_parent_dirs(files: list[tuple[str, str]]) -> list[str]:
return sorted({str(Path(remote).parent) for _, remote in files})
def _sha256_file(path: str) -> str:
"""Return hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
_SYNC_BACK_MAX_RETRIES = 3
_SYNC_BACK_BACKOFF = (2, 4, 8) # seconds between retries
class FileSyncManager:
"""Tracks local file changes and syncs to a remote environment.
@@ -111,15 +89,12 @@ class FileSyncManager:
delete_fn: DeleteFn,
sync_interval: float = _SYNC_INTERVAL_SECONDS,
bulk_upload_fn: BulkUploadFn | None = None,
bulk_download_fn: BulkDownloadFn | None = None,
):
self._get_files_fn = get_files_fn
self._upload_fn = upload_fn
self._bulk_upload_fn = bulk_upload_fn
self._bulk_download_fn = bulk_download_fn
self._delete_fn = delete_fn
self._synced_files: dict[str, tuple[float, int]] = {} # remote_path -> (mtime, size)
self._pushed_hashes: dict[str, str] = {} # remote_path -> sha256 hex digest
self._last_sync_time: float = 0.0 # monotonic; 0 ensures first sync runs
self._sync_interval = sync_interval
@@ -161,7 +136,6 @@ class FileSyncManager:
# Snapshot for rollback (only when there's work to do)
prev_files = dict(self._synced_files)
prev_hashes = dict(self._pushed_hashes)
if to_upload:
logger.debug("file_sync: uploading %d file(s)", len(to_upload))
@@ -182,176 +156,13 @@ class FileSyncManager:
logger.debug("file_sync: deleted %s", to_delete)
# --- Commit (all succeeded) ---
for host_path, remote_path in to_upload:
self._pushed_hashes[remote_path] = _sha256_file(host_path)
for p in to_delete:
new_files.pop(p, None)
self._pushed_hashes.pop(p, None)
self._synced_files = new_files
self._last_sync_time = time.monotonic()
except Exception as exc:
self._synced_files = prev_files
self._pushed_hashes = prev_hashes
self._last_sync_time = time.monotonic()
logger.warning("file_sync: sync failed, rolled back state: %s", exc)
# ------------------------------------------------------------------
# Sync-back: pull remote changes to host on teardown
# ------------------------------------------------------------------
def sync_back(self, hermes_home: Path | None = None) -> None:
"""Pull remote changes back to the host filesystem.
Downloads the remote ``.hermes/`` directory as a tar archive,
unpacks it, and applies only files that differ from what was
originally pushed (based on SHA-256 content hashes).
Protected against SIGINT (defers the signal until complete) and
serialized across concurrent gateway sandboxes via file lock.
"""
if self._bulk_download_fn is None:
return
lock_path = (hermes_home or get_hermes_home()) / ".sync.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
last_exc: Exception | None = None
for attempt in range(_SYNC_BACK_MAX_RETRIES):
try:
self._sync_back_once(lock_path)
return
except Exception as exc:
last_exc = exc
if attempt < _SYNC_BACK_MAX_RETRIES - 1:
delay = _SYNC_BACK_BACKOFF[attempt]
logger.warning(
"sync_back: attempt %d failed (%s), retrying in %ds",
attempt + 1, exc, delay,
)
time.sleep(delay)
logger.warning("sync_back: all %d attempts failed: %s", _SYNC_BACK_MAX_RETRIES, last_exc)
def _sync_back_once(self, lock_path: Path) -> None:
"""Single sync-back attempt with SIGINT protection and file lock."""
# signal.signal() only works from the main thread. In gateway
# contexts cleanup() may run from a worker thread — skip SIGINT
# deferral there rather than crashing.
on_main_thread = threading.current_thread() is threading.main_thread()
deferred_sigint: list[object] = []
original_handler = None
if on_main_thread:
original_handler = signal.getsignal(signal.SIGINT)
def _defer_sigint(signum, frame):
deferred_sigint.append((signum, frame))
logger.debug("sync_back: SIGINT deferred until sync completes")
signal.signal(signal.SIGINT, _defer_sigint)
try:
self._sync_back_locked(lock_path)
finally:
if on_main_thread and original_handler is not None:
signal.signal(signal.SIGINT, original_handler)
if deferred_sigint:
os.kill(os.getpid(), signal.SIGINT)
def _sync_back_locked(self, lock_path: Path) -> None:
"""Sync-back under file lock (serializes concurrent gateways)."""
lock_fd = open(lock_path, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
self._sync_back_impl()
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _sync_back_impl(self) -> None:
"""Download, diff, and apply remote changes to host."""
assert self._bulk_download_fn is not None
with tempfile.NamedTemporaryFile(suffix=".tar") as tf:
self._bulk_download_fn(Path(tf.name))
with tempfile.TemporaryDirectory(prefix="hermes-sync-back-") as staging:
with tarfile.open(tf.name) as tar:
tar.extractall(staging, filter="data")
applied = 0
for dirpath, _dirnames, filenames in os.walk(staging):
for fname in filenames:
staged_file = os.path.join(dirpath, fname)
rel = os.path.relpath(staged_file, staging)
remote_path = "/" + rel
remote_hash = _sha256_file(staged_file)
pushed_hash = self._pushed_hashes.get(remote_path)
if remote_hash == pushed_hash:
continue
# Resolve host path from get_files_fn mapping
host_path = self._resolve_host_path(remote_path)
if host_path is None:
# New file created on remote — find host base
# by mapping from remote prefix to host prefix.
host_path = self._infer_host_path(remote_path)
if host_path is None:
logger.debug(
"sync_back: skipping %s (no host mapping)",
remote_path,
)
continue
if os.path.exists(host_path) and pushed_hash is not None:
host_hash = _sha256_file(host_path)
if host_hash != pushed_hash:
logger.warning(
"sync_back: conflict on %s — host modified "
"since push, remote also changed. Applying "
"remote version (last-write-wins).",
remote_path,
)
os.makedirs(os.path.dirname(host_path), exist_ok=True)
shutil.copy2(staged_file, host_path)
applied += 1
if applied:
logger.info("sync_back: applied %d changed file(s)", applied)
else:
logger.debug("sync_back: no remote changes detected")
def _resolve_host_path(self, remote_path: str) -> str | None:
"""Find the host path for a known remote path from the file mapping."""
try:
for host, remote in self._get_files_fn():
if remote == remote_path:
return host
except Exception:
pass
return None
def _infer_host_path(self, remote_path: str) -> str | None:
"""Infer a host path for a new remote file by matching path prefixes.
Uses the existing file mapping to find a remote->host directory
pair, then applies the same prefix substitution to the new file.
For example, if the mapping has ``/root/.hermes/skills/a.md``
``~/.hermes/skills/a.md``, a new remote file at
``/root/.hermes/skills/b.md`` maps to ``~/.hermes/skills/b.md``.
"""
try:
for host, remote in self._get_files_fn():
remote_dir = str(Path(remote).parent)
if remote_path.startswith(remote_dir + "/"):
host_dir = str(Path(host).parent)
suffix = remote_path[len(remote_dir):]
return host_dir + suffix
except Exception:
pass
return None
-23
View File
@@ -22,7 +22,6 @@ from tools.environments.base import (
_save_json_store,
)
from tools.environments.file_sync import (
BulkDownloadFn,
FileSyncManager,
iter_sync_files,
quoted_mkdir_command,
@@ -270,7 +269,6 @@ class ModalEnvironment(BaseEnvironment):
upload_fn=self._modal_upload,
delete_fn=self._modal_delete,
bulk_upload_fn=self._modal_bulk_upload,
bulk_download_fn=self._modal_bulk_download,
)
self._sync_manager.sync(force=True)
self.init_session()
@@ -349,23 +347,6 @@ class ModalEnvironment(BaseEnvironment):
self._worker.run_coroutine(_bulk(), timeout=120)
def _modal_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
async def _download():
proc = await self._sandbox.exec.aio(
"bash", "-c", "tar cf - -C / root/.hermes"
)
data = await proc.stdout.read.aio()
exit_code = await proc.wait.aio()
if exit_code != 0:
raise RuntimeError(f"Modal bulk download failed (exit {exit_code})")
return data
tar_bytes = self._worker.run_coroutine(_download(), timeout=120)
if isinstance(tar_bytes, str):
tar_bytes = tar_bytes.encode()
dest.write_bytes(tar_bytes)
def _modal_delete(self, remote_paths: list[str]) -> None:
"""Batch-delete remote files via exec."""
rm_cmd = quoted_rm_command(remote_paths)
@@ -423,10 +404,6 @@ class ModalEnvironment(BaseEnvironment):
if self._sandbox is None:
return
if self._sync_manager:
logger.info("Modal: syncing files from sandbox...")
self._sync_manager.sync_back()
if self._persistent:
try:
async def _snapshot():
-18
View File
@@ -10,7 +10,6 @@ from pathlib import Path
from tools.environments.base import BaseEnvironment, _popen_bash
from tools.environments.file_sync import (
BulkDownloadFn,
FileSyncManager,
iter_sync_files,
quoted_mkdir_command,
@@ -59,7 +58,6 @@ class SSHEnvironment(BaseEnvironment):
upload_fn=self._scp_upload,
delete_fn=self._ssh_delete,
bulk_upload_fn=self._ssh_bulk_upload,
bulk_download_fn=self._ssh_bulk_download,
)
self._sync_manager.sync(force=True)
@@ -218,18 +216,6 @@ class SSHEnvironment(BaseEnvironment):
logger.debug("SSH: bulk-uploaded %d file(s) via tar pipe", len(files))
def _ssh_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
# Tar from / with the full path so archive entries preserve absolute
# paths (e.g. home/user/.hermes/skills/f.py), matching _pushed_hashes keys.
rel_base = f"{self._remote_home}/.hermes".lstrip("/")
ssh_cmd = self._build_ssh_command()
ssh_cmd.append(f"tar cf - -C / {shlex.quote(rel_base)}")
with open(dest, "wb") as f:
result = subprocess.run(ssh_cmd, stdout=f, stderr=subprocess.PIPE, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"SSH bulk download failed: {result.stderr.decode(errors='replace').strip()}")
def _ssh_delete(self, remote_paths: list[str]) -> None:
"""Batch-delete remote files in one SSH call."""
cmd = self._build_ssh_command()
@@ -259,10 +245,6 @@ class SSHEnvironment(BaseEnvironment):
return _popen_bash(cmd, stdin_data)
def cleanup(self):
if self._sync_manager:
logger.info("SSH: syncing files from sandbox...")
self._sync_manager.sync_back()
if self.control_socket.exists():
try:
cmd = ["ssh", "-o", f"ControlPath={self.control_socket}",
+14 -1
View File
@@ -136,6 +136,10 @@ class ProcessRegistry:
import queue as _queue_mod
self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()
# Track sessions whose completion was already consumed by the agent
# via wait/poll/log. Drain loops skip notifications for these.
self._completion_consumed: set = set()
@staticmethod
def _clean_shell_noise(text: str) -> str:
"""Strip shell startup warnings from the beginning of output."""
@@ -613,6 +617,10 @@ class ProcessRegistry:
# ----- Query Methods -----
def is_completion_consumed(self, session_id: str) -> bool:
"""Check if a completion notification was already consumed via wait/poll/log."""
return session_id in self._completion_consumed
def get(self, session_id: str) -> Optional[ProcessSession]:
"""Get a session by ID (running or finished)."""
with self._lock:
@@ -640,6 +648,7 @@ class ProcessRegistry:
}
if session.exited:
result["exit_code"] = session.exit_code
self._completion_consumed.add(session_id)
if session.detached:
result["detached"] = True
result["note"] = "Process recovered after restart -- output history unavailable"
@@ -665,13 +674,16 @@ class ProcessRegistry:
else:
selected = lines[offset:offset + limit]
return {
result = {
"session_id": session.id,
"status": "exited" if session.exited else "running",
"output": "\n".join(selected),
"total_lines": total_lines,
"showing": f"{len(selected)} lines",
}
if session.exited:
self._completion_consumed.add(session_id)
return result
def wait(self, session_id: str, timeout: int = None) -> dict:
"""
@@ -714,6 +726,7 @@ class ProcessRegistry:
while time.monotonic() < deadline:
session = self._refresh_detached_session(session)
if session.exited:
self._completion_consumed.add(session_id)
result = {
"status": "exited",
"exit_code": session.exit_code,
+3 -2
View File
@@ -106,8 +106,9 @@ def detect_audio_environment() -> dict:
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
warnings.append("Running over SSH -- no audio devices available")
# Docker detection
if os.path.exists('/.dockerenv'):
# Docker/Podman container detection
from hermes_constants import is_container
if is_container():
warnings.append("Running inside Docker container -- no audio devices")
# WSL detection — PulseAudio bridge makes audio work in WSL.
Generated
+63
View File
@@ -165,6 +165,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "altair"
version = "6.0.0"
@@ -240,6 +249,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "atroposlib"
version = "0.4.0"
@@ -1672,6 +1729,8 @@ acp = [
all = [
{ name = "agent-client-protocol" },
{ name = "aiohttp" },
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
{ name = "croniter" },
{ name = "daytona" },
{ name = "debugpy" },
@@ -1727,6 +1786,8 @@ honcho = [
{ name = "honcho-ai" },
]
matrix = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "markdown" },
{ name = "mautrix", extra = ["encryption"] },
]
@@ -1791,7 +1852,9 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
{ name = "anthropic", specifier = ">=0.39.0,<1" },
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" },
{ name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
+2 -2
View File
@@ -23,7 +23,7 @@ hermes claw migrate --preset full --yes
The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed.
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`).
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moltbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moltbot.json`).
## Options
@@ -234,7 +234,7 @@ The migration resolves all three formats. For env templates and SecretRef object
### "OpenClaw directory not found"
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moltbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
### "No provider API keys found"
+1 -1
View File
@@ -660,7 +660,7 @@ hermes insights [--days N] [--source platform]
hermes claw migrate [options]
```
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`).
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`).
| Option | Description |
|--------|-------------|
@@ -277,6 +277,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |
| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |
| `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) |
| `MATRIX_RECOVERY_KEY` | Recovery key for cross-signing verification after device key rotation. Recommended for E2EE setups with cross-signing enabled. |
| `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
| `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
| `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |
+93 -1
View File
@@ -272,6 +272,18 @@ When E2EE is enabled, Hermes:
- Decrypts incoming messages and encrypts outgoing messages automatically
- Auto-joins encrypted rooms when invited
### Cross-Signing Verification (Recommended)
If your Matrix account has cross-signing enabled (the default in Element), set the recovery key so the bot can self-sign its device on startup. Without this, other Matrix clients may refuse to share encryption sessions with the bot after a device key rotation.
```bash
MATRIX_RECOVERY_KEY=EsT... your recovery key here
```
**Where to find it:** In Element, go to **Settings****Security & Privacy****Encryption** → your recovery key (also called the "Security Key"). This is the key you were asked to save when you first set up cross-signing.
On each startup, if `MATRIX_RECOVERY_KEY` is set, Hermes imports cross-signing keys from the homeserver's secure secret storage and signs the current device. This is idempotent and safe to leave enabled permanently.
:::warning
If you delete the `~/.hermes/platforms/matrix/store/` directory, the bot loses its encryption keys. You'll need to verify the device again in your Matrix client. Back up this directory if you want to preserve encrypted sessions.
:::
@@ -344,9 +356,89 @@ pip install 'hermes-agent[matrix]'
**Fix**:
1. Verify `libolm` is installed on your system (see the E2EE section above).
2. Make sure `MATRIX_ENCRYPTION=true` is set in your `.env`.
3. In your Matrix client (Element), go to the bot's profile **Sessions** verify/trust the bot's device.
3. In your Matrix client (Element), go to the bot's profile -> Sessions -> verify/trust the bot's device.
4. If the bot just joined an encrypted room, it can only decrypt messages sent *after* it joined. Older messages are inaccessible.
### Upgrading from a previous version with E2EE
If you previously used Hermes with `MATRIX_ENCRYPTION=true` and are upgrading to
a version that uses the new SQLite-based crypto store, the bot's encryption
identity has changed. Your Matrix client (Element) may cache the old device keys
and refuse to share encryption sessions with the bot.
**Symptoms**: The bot connects and shows "E2EE enabled" in the logs, but all
messages show "could not decrypt event" and the bot never responds.
**What's happening**: The old encryption state (from the previous `matrix-nio` or
serialization-based `mautrix` backend) is incompatible with the new SQLite crypto
store. The bot creates a fresh encryption identity, but your Matrix client still
has the old keys cached and won't share the room's encryption session with a
device whose keys changed. This is a Matrix security feature -- clients treat
changed identity keys for the same device as suspicious.
**Fix** (one-time migration):
1. **Generate a new access token** to get a fresh device ID. The simplest way:
```bash
curl -X POST https://your-server/_matrix/client/v3/login \
-H "Content-Type: application/json" \
-d '{
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": "@hermes:your-server.org"},
"password": "***",
"initial_device_display_name": "Hermes Agent"
}'
```
Copy the new `access_token` and update `MATRIX_ACCESS_TOKEN` in `~/.hermes/.env`.
2. **Delete old encryption state**:
```bash
rm -f ~/.hermes/platforms/matrix/store/crypto.db
rm -f ~/.hermes/platforms/matrix/store/crypto_store.*
```
3. **Set your recovery key** (if you use cross-signing — most Element users do). Add to `~/.hermes/.env`:
```bash
MATRIX_RECOVERY_KEY=EsT... your recovery key here
```
This lets the bot self-sign with cross-signing keys on startup, so Element trusts the new device immediately. Without this, Element may see the new device as unverified and refuse to share encryption sessions. Find your recovery key in Element under **Settings****Security & Privacy****Encryption**.
4. **Force your Matrix client to rotate the encryption session**. In Element,
open the DM room with the bot and type `/discardsession`. This forces Element
to create a new encryption session and share it with the bot's new device.
5. **Restart the gateway**:
```bash
hermes gateway run
```
If `MATRIX_RECOVERY_KEY` is set, you should see `Matrix: cross-signing verified via recovery key` in the logs.
6. **Send a new message**. The bot should decrypt and respond normally.
:::note
After migration, messages sent *before* the upgrade cannot be decrypted -- the old
encryption keys are gone. This only affects the transition; new messages work
normally.
:::
:::tip
**New installations are not affected.** This migration is only needed if you had
a working E2EE setup with a previous version of Hermes and are upgrading.
**Why a new access token?** Each Matrix access token is bound to a specific device
ID. Reusing the same device ID with new encryption keys causes other Matrix
clients to distrust the device (they see changed identity keys as a potential
security breach). A new access token gets a new device ID with no stale key
history, so other clients trust it immediately.
:::
### Sync issues / bot falls behind
**Cause**: Long-running tool executions can delay the sync loop, or the homeserver is slow.