Makes provider fallback turn-scoped in long-lived CLI sessions. Previously, a single transient failure pinned the session to the fallback provider for every subsequent turn.
- _primary_runtime dict snapshot at __init__ (model, provider, base_url, api_mode, client_kwargs, compressor state)
- _restore_primary_runtime() at top of run_conversation() — restores all state, resets fallback chain index
- _try_recover_primary_transport() — one extra recovery cycle (client rebuild + cooldown) for transient transport errors on direct endpoints before fallback
- Skipped for aggregator providers (OpenRouter, Nous)
- 25 tests
Inspired by #4612 (@betamod). Closes#4612.
Adds a Skills Hub page to the documentation site with browsable/searchable catalog of all skills (built-in, optional, and community from cached hub indexes).
- Python extraction script (website/scripts/extract-skills.py) parses SKILL.md frontmatter and hub index caches into skills.json
- React page (website/src/pages/skills/) with search, category filtering, source filtering, and expandable skill cards
- CI workflow updated to run extraction before Docusaurus build
- Deploy trigger expanded to include skills/ and optional-skills/ changes
Authored by @IAvecilla
Anthropic extended thinking blocks include an opaque 'signature' field
required for thinking chain continuity across multi-turn tool-use
conversations. Previously, normalize_anthropic_response() extracted
only the thinking text and set reasoning_details=None, discarding the
signature. On subsequent turns the API could not verify the chain.
Changes:
- _to_plain_data(): new recursive SDK-to-dict converter with depth cap
(20 levels) and path-based cycle detection for safety
- _extract_preserved_thinking_blocks(): rehydrates preserved thinking
blocks (including signature) from reasoning_details on assistant
messages, placing them before tool_use blocks as Anthropic requires
- normalize_anthropic_response(): stores full thinking blocks in
reasoning_details via _to_plain_data()
- _extract_reasoning(): adds 'thinking' key to the detail lookup chain
so Anthropic-format details are found alongside OpenRouter format
Salvaged from PR #4503 by @priveperfumes — focused on the thinking
block continuity fix only (cache strategy and other changes excluded).
OpenCode Zen and Go are mixed-API-surface providers — different models
behind them use different API surfaces (GPT on Zen uses codex_responses,
Claude on Zen uses anthropic_messages, MiniMax on Go uses
anthropic_messages, GLM/Kimi on Go use chat_completions).
Changes:
- Add normalize_opencode_model_id() and opencode_model_api_mode() to
models.py for model ID normalization and API surface routing
- Add _provider_supports_explicit_api_mode() to runtime_provider.py
to prevent stale api_mode from leaking across provider switches
- Wire opencode routing into all three api_mode resolution paths:
pool entry, api_key provider, and explicit runtime
- Add api_mode field to ModelSwitchResult for propagation through the
switch pipeline
- Consolidate _PROVIDER_MODELS from main.py into models.py (single
source of truth, eliminates duplicate dict)
- Add opencode normalization to setup wizard and model picker flows
- Add opencode block to _normalize_model_for_provider in CLI
- Add opencode-zen/go fallback model lists to setup.py
Tests: 160 targeted tests pass (26 new tests covering normalization,
api_mode routing per provider/model, persistence, and setup wizard
normalization).
Based on PR #3017 by SaM13997.
Co-authored-by: SaM13997 <139419381+SaM13997@users.noreply.github.com>
- Remove duplicate cmd_sync definition (kept version with error output)
- Fix from_env workspace to stay shared (hermes) not profile-derived
- Add docstring clarifying get_or_create is idempotent in status
- Remove unused import importlib in test
- Fix test assertion for shared workspace in from_env path
- Add 3 tests for sync_honcho_profiles_quiet
- hermes honcho sync: scan all profiles, create missing host blocks
- hermes update: automatically syncs Honcho config to all profiles
after skill sync (existing users get profile mapping on next update)
- sync_honcho_profiles_quiet() for silent use from update path
- hermes honcho --target-profile <name> <command>: target another
profile's Honcho config without switching profiles. Works with all
subcommands (status, peer, mode, tokens, enable, disable, etc.)
- hermes honcho status now shows user peer card and AI peer
representation when connected (fetched live from Honcho API)
Profiles inherit the default workspace instead of deriving a separate
one. All profiles see the same user context, sessions, and project
history. Each profile is a different AI peer in a shared space.
Workspace can still be overridden per-profile via config if isolation
is needed.
- Eagerly create AI and user peers in Honcho when a profile is created
(not deferred to first message). Uses idempotent peer() SDK call.
- hermes honcho enable: turn on Honcho for active profile, clone
settings from default if first time, create peer immediately
- hermes honcho disable: turn off Honcho for active profile
- _ensure_peer_exists() helper for idempotent peer creation
When a profile is created and Honcho is already configured on the
default host, automatically creates a host block for the new profile
with inherited settings (memory mode, recall mode, write frequency,
peer name, etc.) and auto-derived workspace/aiPeer.
Zero-friction path: hermes profile create coder -> Honcho config
cloned as hermes.coder with all settings inherited.
- hermes honcho status: shows active profile name + host key
- hermes honcho status --all: compact table of all profiles with mode,
recall, write frequency per host block
- hermes honcho peers: cross-profile peer identity table (user peer,
AI peer, linked hosts)
- All write commands (peer, mode, tokens) print [host_key] label when
operating on a non-default profile
Derives the Honcho host key from the active Hermes profile so that each
profile gets its own Honcho host block, workspace, and AI peer identity.
Profile "coder" resolves to host "hermes.coder", reads from
hosts["hermes.coder"] in honcho.json, and defaults workspace + aiPeer
to the derived host name.
Resolution order: HERMES_HONCHO_HOST env var > active profile name >
"hermes" (default).
Complements #3681 (profiles) with the Honcho identity layer that was
part of #2845 (named instances), adapted to the merged profiles system.
python-olm (required by matrix-nio[e2e]) fails to build on modern macOS:
- CMake 4 rejects vendored libolm's cmake_minimum_required(VERSION 3.4)
- Apple Clang 21+ rejects a C++ type error in include/olm/list.hh
- Upstream libolm repo is archived, no fix forthcoming
Including matrix in [all] causes the entire extras install to fail during
`hermes update`, silently dropping all other extras (telegram, discord,
slack, cron, etc.) when the fallback kicks in.
The [matrix] extra is preserved for opt-in install:
pip install 'hermes-agent[matrix]'
Closes#4178
Three root causes addressed:
1. AIAgent no longer defaults base_url to OpenRouter (9 tests)
Tests that assert OpenRouter-specific behavior (prompt caching,
reasoning extra_body, provider preferences) need explicit base_url
and model set on the agent. Updated test_run_agent.py and
test_provider_parity.py.
2. Credential pool auto-seeding from host env (2 tests)
test_auxiliary_client.py tests for Anthropic OAuth and custom
endpoint fallback were not mocking _select_pool_entry, so the
host's credential pool interfered. Added pool + codex mocks.
3. sys.modules corruption cascade (major - ~250 tests)
test_managed_modal_environment.py replaced sys.modules entries
(tools, hermes_cli, agent packages) with SimpleNamespace stubs
but had NO cleanup fixture. Every subsequent test in the process
saw corrupted imports: 'cannot import get_config_path from
<unknown module name>' and 'module tools has no attribute
environments'. Added _restore_tool_and_agent_modules autouse
fixture matching the pattern in test_managed_browserbase_and_modal.py.
This was also the root cause of CI failures (104 failed on main).
The gateway's dangerous command approval system was fundamentally broken:
the agent loop continued running after a command was flagged, and the
approval request only reached the user after the agent finished its
entire conversation loop. By then the context was lost.
This change makes the gateway approval mirror the CLI's synchronous
behavior. When a dangerous command is detected:
1. The agent thread blocks on a threading.Event
2. The approval request is sent to the user immediately
3. The user responds with /approve or /deny
4. The event is signaled and the agent resumes with the real result
The agent never sees 'approval_required' as a tool result. It either
gets the command output (approved) or a definitive BLOCKED message
(denied/timed out) — same as CLI mode.
Queue-based design supports multiple concurrent approvals (parallel
subagents via delegate_task, execute_code RPC handlers). Each approval
gets its own _ApprovalEntry with its own threading.Event. /approve
resolves the oldest (FIFO); /approve all resolves all at once.
Changes:
- tools/approval.py: Queue-based per-session blocking gateway approval
(register/unregister callbacks, resolve with FIFO or all-at-once)
- gateway/run.py: Register approval callback in run_sync(), remove
post-loop pop_pending hack, /approve and /deny support 'all' flag
- tests: 21 tests including parallel subagent E2E scenarios
Setup wizard now shows existing allowed_users when reconfiguring a
platform and preserves them if the user presses Enter. Previously the
wizard would display a misleading "No allowlist set" warning even when
the .env still held the original IDs.
Also downgrades the "provider X has no API key configured" log from
WARNING to DEBUG in resolve_provider_client — callers already handle
the None return with their own contextual messages. This eliminates
noisy startup warnings for providers in the fallback chain that the
user never configured (e.g. minimax).
hermes update only cleared .update_check for the active HERMES_HOME,
leaving other profiles showing stale 'N commits behind' in their banner.
Now _invalidate_update_cache() iterates over ~/.hermes/ (default) plus
every directory under ~/.hermes/profiles/ to clear all caches. The git
repo is shared across profiles so a single update brings them all current.
Reported by SteveSkedasticity on Discord.
The original PR applied dotted and hyphenated regex quoting in two
sequential steps. For terms with both dots and hyphens (e.g.
my-app.config.ts), step 2 would re-match inside already-quoted output,
producing malformed double-quoted FTS5 syntax.
Merged into a single regex pass: \w+(?:[.-]\w+)+ — handles dots,
hyphens, and mixed terms in one shot. Added test coverage for the
mixed case.
Extract the inline file-drop detection logic into a standalone
_detect_file_drop() function at module level for testability. The main
loop now calls this function instead of inlining the logic.
Tests cover:
- Slash commands still route correctly (/help, /quit, /xyz)
- Image paths auto-detected (.png, .jpg, .gif, etc.)
- Non-image files detected (.py, .txt, Makefile, etc.)
- Backslash-escaped spaces from macOS drag-and-drop
- Trailing user text preserved as remainder
- Edge cases: directories, symlinks, no-extension files
- Non-string input, empty strings, nonexistent paths
When a user drags a file into the terminal, macOS pastes the absolute
path (e.g. /Users/roland/Desktop/Screenshot.png) which starts with '/'
and was incorrectly routed to process_command(), producing an 'Unknown
command' error.
This change adds file-path detection before the slash-command check:
- Parses the first token, handling backslash-escaped spaces from macOS
- Checks if the path exists as a real file via Path.exists()
- Image files (.png, .jpg, etc.) are auto-attached to the message
- Non-image files are reformatted as [User attached file: ...] context
- Falls through to normal slash-command handling if not a real file path
_load_installable_optional_extras() was returning ALL extras from
pyproject.toml except 'all', which included 'rl' and 'yc-bench' —
extras not referenced by [all] that install heavy research deps
(atroposlib, tinker, wandb) from git repos. Changed to parse the
[all] group's references and only retry those 18 extras.
Also moved tomllib import to function-level since it only runs
during the rare fallback path.
- Add logger + debug log to read_nous_access_token() catch-all so token
refresh failures are observable instead of silently swallowed
- Tighten _is_nous_auxiliary_client() domain check to use proper URL
hostname parsing instead of substring match, preventing false-positives
on domains like not-nousresearch.com or nousresearch.com.evil.com
- Add missing `from agent.credential_pool import load_pool` import to
auxiliary_client.py (introduced by the credential pool feature in main)
- Thread `args` through `select_provider_and_model(args=None)` so TLS
options from `cmd_model` reach `_model_flow_nous`
- Mock `_require_tty` in test_cmd_model_forwards_nous_login_tls_options
so it can run in non-interactive test environments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move e2e tests into tests.yml as a parallel job instead of a separate
workflow. Unit tests now also ignore tests/e2e/ to avoid running them
twice. Both jobs appear as independent checks in the PR.
New test classes:
- TestSessionLifecycle: /new then /status sequence, idempotent resets
- TestAuthorization: unauthorized users get pairing code, not commands
- TestSendFailureResilience: pipeline survives send() failures
Additional command coverage: /provider, /verbose, /personality, /yolo.
Note: /provider test is xfail - found a real bug where model_cfg is
referenced unbound when config.yaml is absent (run.py:3247).
Separate workflow for gateway e2e tests, runs on push/PR to main.
Same Python 3.11 + uv setup as existing tests.yml but targets only
tests/e2e/ with verbose output.
Tests /help, /status, /new, /stop, /commands through the full adapter
background-task pipeline. Validates command dispatch, session lifecycle,
and response delivery without any LLM involvement.
Fixtures and helpers for driving messages through the full async
pipeline: adapter.handle_message → background task → GatewayRunner
command dispatch → adapter.send (mocked).
Uses the established _make_runner pattern (object.__new__) to skip
filesystem side effects while exercising real command dispatch logic.
No model, base_url, or provider is assumed when the user hasn't
configured one. Previously the defaults dict in cli.py, AIAgent
constructor args, and several fallback paths all hardcoded
anthropic/claude-opus-4.6 + openrouter.ai/api/v1 — silently routing
unconfigured users to OpenRouter, which 404s for anyone using a
different provider.
Now empty defaults force the setup wizard to run, and existing users
who already completed setup are unaffected (their config.yaml has
the model they chose).
Files changed:
- cli.py: defaults dict, _DEFAULT_CONFIG_MODEL
- run_agent.py: AIAgent.__init__ defaults, main() defaults
- hermes_cli/config.py: DEFAULT_CONFIG
- hermes_cli/runtime_provider.py: is_fallback sentinel
- acp_adapter/session.py: default_model
- tests: updated to reflect empty defaults
OpenAI's newer models (GPT-5, Codex) give stronger instruction-following
weight to the 'developer' role vs 'system'. Swap the role at the API
boundary in _build_api_kwargs() for the chat_completions path so internal
message representation stays consistent ('system' everywhere).
Applies regardless of provider — OpenRouter, Nous portal, direct, etc.
The codex_responses path (direct OpenAI) uses 'instructions' instead of
message roles, so it's unaffected.
DEVELOPER_ROLE_MODELS constant in prompt_builder.py defines the matching
model name substrings: ('gpt-5', 'codex').
When a profile config sets model.model but not model.default, the
hardcoded default (claude-opus-4.6) survived the config merge and
took precedence in HermesCLI.__init__ because it checks model.default
first. Profile model configs were silently ignored.
Now model.model is promoted to model.default during the merge when the
user didn't explicitly set model.default. Fixes#4486.
* fix: force-close TCP sockets on client cleanup, detect and recover dead connections
When a provider drops connections mid-stream (e.g. OpenRouter outage),
httpx's graceful close leaves sockets in CLOSE-WAIT indefinitely. These
zombie connections accumulate and can prevent recovery without restarting.
Changes:
- _force_close_tcp_sockets: walks the httpx connection pool and issues
socket.shutdown(SHUT_RDWR) + close() to force TCP RST on every socket
when a client is closed, preventing CLOSE-WAIT accumulation
- _cleanup_dead_connections: probes the primary client's pool for dead
sockets (recv MSG_PEEK), rebuilds the client if any are found
- Pre-turn health check at the start of each run_conversation call that
auto-recovers with a user-facing status message
- Primary client rebuild after stale stream detection to purge pool
- User-facing messages on streaming connection failures:
"Connection to provider dropped — Reconnecting (attempt 2/3)"
"Connection failed after 3 attempts — try again in a moment"
Made-with: Cursor
* fix: pool entry missing base_url for openrouter, clean error messages
- _resolve_runtime_from_pool_entry: add OPENROUTER_BASE_URL fallback
when pool entry has no runtime_base_url (pool entries from auth.json
credential_pool often omit base_url)
- Replace Rich console.print for auth errors with plain print() to
prevent ANSI escape code mangling through prompt_toolkit's stdout patch
- Force-close TCP sockets on client cleanup to prevent CLOSE-WAIT
accumulation after provider outages
- Pre-turn dead connection detection with auto-recovery and user message
- Primary client rebuild after stale stream detection
- User-facing status messages on streaming connection failures/retries
Made-with: Cursor
* fix(gateway): persist memory flush state to prevent redundant re-flushes on restart
The _session_expiry_watcher tracked flushed sessions in an in-memory set
(_pre_flushed_sessions) that was lost on gateway restart. Expired sessions
remained in sessions.json and were re-discovered every restart, causing
redundant AIAgent runs that burned API credits and blocked the event loop.
Fix: Add a memory_flushed boolean field to SessionEntry, persisted in
sessions.json. The watcher sets it after a successful flush. On restart,
the flag survives and the watcher skips already-flushed sessions.
- Add memory_flushed field to SessionEntry with to_dict/from_dict support
- Old sessions.json entries without the field default to False (backward compat)
- Remove the ephemeral _pre_flushed_sessions set from SessionStore
- Update tests: save/load roundtrip, legacy entry compat, auto-reset behavior
The original test file had mock secrets corrupted by secret-redaction
tooling before commit — the test values (sk-ant...l012) didn't actually
trigger the PREFIX_RE regex, so 4 of 10 tests were asserting against
values that never appeared in the input.
- Replace truncated mock values with proper fake keys built via string
concatenation (avoids tool redaction during file writes)
- Add _ensure_redaction_enabled autouse fixture to patch the module-level
_REDACT_ENABLED constant, matching the pattern from test_redact.py