Compare commits

..

117 Commits

Author SHA1 Message Date
Teknium 8369bc9db2 Add hermes debug share instructions to all issue templates
- bug_report.yml: Add required Debug Report section with hermes debug share
  and /debug instructions, make OS/Python/Hermes version optional (covered
  by debug report), demote old logs field to optional supplementary
- setup_help.yml: Replace hermes doctor reference with hermes debug share,
  add Debug Report section with fallback chain (debug share -> --local -> doctor)
- feature_request.yml: Add optional Debug Report section for environment context

All templates now guide users to run hermes debug share (or /debug in chat)
and paste the resulting paste.rs links, giving maintainers system info,
config, and recent logs in one step.
2026-04-13 19:08:56 -07:00
Teknium f6626fccee refactor: remove provider tier system — flat picker in hermes model (#9303)
Remove the two-tier (top/extended) provider picker that hid most
providers behind a 'More providers...' submenu. All providers now
appear in a single flat list.

- Remove tier field from ProviderEntry namedtuple
- Remove tier values from all CANONICAL_PROVIDERS entries
- Flatten the hermes model picker (no more 'More...' submenu)
- Move 'Custom endpoint' to the bottom of the main list
2026-04-13 18:51:13 -07:00
Teknium f324222b79 fix: add vLLM/local server error patterns + MCP initial connection retry (#9281)
Port two improvements inspired by Kilo-Org/kilocode analysis:

1. Error classifier: add context overflow patterns for vLLM, Ollama,
   and llama.cpp/llama-server. These local inference servers return
   different error formats than cloud providers (e.g., 'exceeds the
   max_model_len', 'context length exceeded', 'slot context'). Without
   these patterns, context overflow errors from local servers are
   misclassified as format errors, causing infinite retries instead
   of triggering compression.

2. MCP initial connection retry: previously, if the very first
   connection attempt to an MCP server failed (e.g., transient DNS
   blip at startup), the server was permanently marked as failed with
   no retry. Post-connect reconnection had 5 retries with exponential
   backoff, but initial connection had zero. Now initial connections
   retry up to 3 times with backoff before giving up, matching the
   resilience of post-connect reconnection.
   (Inspired by Kilo Code's MCP server disappearing fix in v1.3.3)

Tests: 6 new error classifier tests, 4 new MCP retry tests, 1
updated existing test. All 276 affected tests pass.
2026-04-13 18:46:14 -07:00
arthurbr11 0a4cf5b3e1 feat(providers): add Arcee AI as direct API provider
Adds Arcee AI as a standard direct provider (ARCEEAI_API_KEY) with
Trinity models: trinity-large-thinking, trinity-large-preview, trinity-mini.

Standard OpenAI-compatible provider checklist: auth.py, config.py,
models.py, main.py, providers.py, doctor.py, model_normalize.py,
model_metadata.py, setup.py, trajectory_compressor.py.

Based on PR #9274 by arthurbr11, simplified to a standard direct
provider without dual-endpoint OpenRouter routing.
2026-04-13 18:40:06 -07:00
Agent 78fa758451 feat(web): make Web UI responsive for mobile
- Nav: icons only on mobile, icon+label on sm+
- Brand: abbreviated "H A" on mobile, full "Hermes Agent" on sm+
- Content: reduced padding on mobile (px-3 vs px-6)
- StatusPage: session cards stack vertically on mobile, truncate
  overflow text, strip model namespace for brevity
- ConfigPage: sidebar becomes horizontal scrollable pills on mobile
  instead of fixed left column, search hidden on mobile
- SessionsPage: title + search stack vertically on mobile, search
  goes full-width
- Card component: add overflow-hidden to prevent content bleed
- Body/root: add overflow-x-hidden to prevent horizontal scroll
- Footer: reduced font sizes on mobile

All changes use Tailwind responsive breakpoints (sm: prefix).
No logic changes — purely layout/CSS adjustments.
2026-04-13 17:16:28 -07:00
Teknium ac80bd61ad test: add regression tests for custom_providers multi-model dedup and grouping
Tests for salvaged PRs #9233 and #8011.
2026-04-13 16:41:30 -07:00
Ubuntu ec9bf9e378 feat(model-picker): group custom_providers by name into a single row per provider
The /model picker currently renders one row per ``custom_providers``
entry. When several entries share the same provider name (e.g. four
``ollama-cloud`` entries for ``qwen3-coder``, ``glm-5.1``, ``kimi-k2``,
``minimax-m2.7``), users see four separate "Ollama Cloud" rows in the
picker, which is confusing UX — there is only one Ollama Cloud
provider, so there should be one row containing four models.

This PR groups ``custom_providers`` entries that share the same provider
name into a single picker row while keeping entries with distinct names
as separate rows. So:

* Four entries named ``Ollama Cloud`` → one "Ollama Cloud" row with
  four models inside.
* One entry named ``Ollama Cloud`` and one named ``Moonshot`` → two
  separate rows, one model each.

Implementation
--------------
Replaces the single-pass loop in ``list_authenticated_providers()`` with
a two-pass approach:

1. First pass: build an ``OrderedDict`` keyed by ``custom_provider_slug(name)``,
   accumulating ``models`` per group while preserving discovery order.
2. Second pass: iterate the groups and append one result row per group,
   skipping any slug that already appeared in an earlier provider source
   (the existing ``seen_slugs`` guard).

Insertion order is preserved via ``OrderedDict``, so providers and
their models still appear in the order the user listed them in
``custom_providers``. No new dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:41:30 -07:00
akhater 01f71007d0 fix(config): include model field in custom_providers dedup key
get_compatible_custom_providers() deduplicates by (name, base_url) which
collapses multiple models under the same provider into a single entry.
For example, 7 Ollama Cloud entries with different models become 1.
Adding model to the tuple preserves all entries.
2026-04-13 16:41:30 -07:00
Teknium 32cea0c08d fix: dashboard shows Nous Portal as 'not connected' despite active auth (#9261)
The dashboard device-code flow (_nous_poller in web_server.py) saved
credentials to the credential pool only, while get_nous_auth_status()
only checked the auth store (auth.json). This caused the Keys tab to
show 'not connected' even when the backend was fully authenticated.

Two fixes:
1. get_nous_auth_status() now checks the credential pool first (like
   get_codex_auth_status() already does), then falls back to the auth
   store.
2. _nous_poller now also persists to the auth store after saving to
   the credential pool, matching the CLI flow (_login_nous).

Adds 3 tests covering pool-only, auth-store-fallback, and empty-state
scenarios.
2026-04-13 16:32:11 -07:00
Teknium 8d023e43ed refactor: remove dead code — 1,784 lines across 77 files (#9180)
Deep scan with vulture, pyflakes, and manual cross-referencing identified:
- 41 dead functions/methods (zero callers in production)
- 7 production-dead functions (only test callers, tests deleted)
- 5 dead constants/variables
- ~35 unused imports across agent/, hermes_cli/, tools/, gateway/

Categories of dead code removed:
- Refactoring leftovers: _set_default_model, _setup_copilot_reasoning_selection,
  rebuild_lookups, clear_session_context, get_logs_dir, clear_session
- Unused API surface: search_models_dev, get_pricing, skills_categories,
  get_read_files_summary, clear_read_tracker, menu_labels, get_spinner_list
- Dead compatibility wrappers: schedule_cronjob, list_cronjobs, remove_cronjob
- Stale debug helpers: get_debug_session_info copies in 4 tool files
  (centralized version in debug_helpers.py already exists)
- Dead gateway methods: send_emote, send_notice (matrix), send_reaction
  (bluebubbles), _normalize_inbound_text (feishu), fetch_room_history
  (matrix), _start_typing_indicator (signal), parse_feishu_post_content
- Dead constants: NOUS_API_BASE_URL, SKILLS_TOOL_DESCRIPTION,
  FILE_TOOLS, VALID_ASPECT_RATIOS, MEMORY_DIR
- Unused UI code: _interactive_provider_selection,
  _interactive_model_selection (superseded by prompt_toolkit picker)

Test suite verified: 609 tests covering affected files all pass.
Tests for removed functions deleted. Tests using removed utilities
(clear_read_tracker, MEMORY_DIR) updated to use internal APIs directly.
2026-04-13 16:32:04 -07:00
Teknium a66fc1365d fix: add files:read to SLACK_BOT_TOKEN description in config.py
Missed in the original PR — the env var description also lists required scopes.
2026-04-13 16:31:38 -07:00
helix4u 448b8bfb7c docs: add slack files:read scope 2026-04-13 16:31:38 -07:00
Teknium def8b959b8 fix: add contributor audit script + fix missed contributors (#9264)
Three problems fixed:

1. bobashopcashier missing from v0.9.0 contributor list despite
   authoring the gateway drain PR (#7290, salvaged into #7503).
   Their email (kennyx102@gmail.com) was missing from AUTHOR_MAP.

2. release.py only scanned git commit authors, missing Co-authored-by
   trailers. Now parse_coauthors() extracts trailers from commit bodies.

3. No mechanism to detect contributors from salvaged PRs (where original
   author only appears in PR description, not git log).

Changes:
- scripts/release.py: add kennyx102@gmail.com to AUTHOR_MAP, enhance
  get_commits() to parse Co-authored-by trailers, filter AI assistants
  (Claude, Copilot, Cursor Agent) from co-author lists
- scripts/contributor_audit.py: new script that cross-references git
  authors, co-author trailers, and salvaged PR descriptions. Reports
  unknown emails and contributors missing from release notes.
- RELEASE_v0.9.0.md: add bobashopcashier to community contributors

Usage:
  python scripts/contributor_audit.py --since-tag v2026.4.8
  python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md
2026-04-13 16:31:27 -07:00
helix4u f94f53cc22 fix(matrix): disable streaming cursor decoration on Matrix 2026-04-13 16:31:02 -07:00
helix4u 0ffb6f2dae fix(matrix): skip cursor-only stream placeholder messages 2026-04-13 16:31:02 -07:00
Teknium b27eaaa4db fix: improve ACP type check and restore comment accuracy
- Use isinstance() with try/except import for CopilotACPClient check
  in _to_async_client instead of fragile __class__.__name__ string check
- Restore accurate comment: GPT-5.x models *require* (not 'often require')
  the Responses API on OpenAI/OpenRouter; ACP is the exception, not a
  softening of the requirement
- Add inline comment explaining the ACP exclusion rationale
2026-04-13 16:17:43 -07:00
helix4u 8680f61f8b fix(copilot-acp): keep acp runtime off responses path 2026-04-13 16:17:43 -07:00
Teknium 063244bb16 test: add coverage for plugin context engine init (#9071)
Verify that plugin context engines receive update_model() with correct
context_length during AIAgent init — regression test for the ctx -- bug.
2026-04-13 15:00:57 -07:00
Stephen Schoettler c763ed5801 fix(agent): resolve context_length for plugin context engines
Plugin context engines loaded via load_context_engine() were never
given context_length, causing the CLI status bar to show "ctx --"
with an empty progress bar. Call update_model() immediately after
loading the plugin engine, mirroring what switch_model() already does.

Fixes NousResearch/hermes-agent#9071

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:00:57 -07:00
Teknium 204e9190c4 fix: consolidate provider lists into single CANONICAL_PROVIDERS source of truth (#9237)
Three separate hardcoded provider lists (/model, /provider, hermes model)
diverged over time, causing providers to be missing from some commands.

- Create CANONICAL_PROVIDERS in hermes_cli/models.py as the single source
  of truth for all provider identity, labels, and TUI ordering
- Derive _PROVIDER_LABELS and list_available_providers() from canonical list
- Add step 2b in list_authenticated_providers() to cross-check canonical
  list — catches providers with credentials that weren't found via
  PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS mappings
- Derive hermes model TUI provider menus from canonical list
- Add deepseek and xai as first-class providers (were missing from TUI)
- Add grok/x-ai/x.ai aliases for xai provider

Fixes: /model command not showing all providers that hermes model shows
2026-04-13 14:59:50 -07:00
Teknium 952a885fbf fix(gateway): /stop no longer resets the session (#9224)
/stop was calling suspend_session() which marked the session for auto-reset
on the next message. This meant users lost their conversation history every
time they stopped a running agent — especially painful for untitled sessions
that can't be resumed by name.

Now /stop just interrupts the agent and cleans the session lock. The session
stays intact so users can continue the conversation.

The suspend behavior was introduced in #7536 to break stuck session resume
loops on gateway restart. That case is already handled by
suspend_recently_active() which runs at gateway startup, so removing it from
/stop doesn't regress the original fix.
2026-04-13 14:59:05 -07:00
SHL0MS d5fd74cac2 fix(ci): don't fail supply chain scan when PR comment can't be posted on fork PRs (#6681)
The GITHUB_TOKEN for fork PRs is read-only — gh pr comment fails with
'Resource not accessible by integration'. This caused the supply chain
scan to show a red X on every fork PR even when no findings were detected.

The scan itself still runs and the 'Fail on critical findings' step
still exits 1 on real issues. Only the comment posting is gracefully
skipped for fork PRs.

Closes #6679

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-04-13 13:58:59 -07:00
Teknium a6f07a6c37 docs: fix hermes web → hermes dashboard in web-dashboard.md (#9207)
The actual CLI command is 'hermes dashboard', not 'hermes web'.
cli-commands.md already had the correct name.
2026-04-13 13:26:21 -07:00
Sabin Iacob a27b3c8725 add git to the container installed packages (fixes #8439) 2026-04-13 13:08:19 -07:00
Teknium 1af2e18d40 chore: release v0.9.0 (v2026.4.13) (#9182)
The everywhere release — Hermes goes mobile with Termux/Android, adds
iMessage and WeChat, ships Fast Mode for OpenAI and Anthropic,
introduces background process monitoring, launches a local web
dashboard, and delivers the deepest security hardening pass yet
across 16 supported platforms.

487 commits, 269 merged PRs, 167 resolved issues, 24 contributors.
2026-04-13 11:52:09 -07:00
Teknium 0e60a9dc25 fix: add kimi-coding-cn to remaining provider touchpoints
Follow-up for salvaged PR #7637. Adds kimi-coding-cn to:
- model_normalize.py (prefix strip)
- providers.py (models.dev mapping)
- runtime_provider.py (credential resolution)
- setup.py (model list + setup label)
- doctor.py (health check)
- trajectory_compressor.py (URL detection)
- models_dev.py (registry mapping)
- integrations/providers.md (docs)
2026-04-13 11:20:37 -07:00
hcshen0111 2b3aa36242 feat(providers): add kimi-coding-cn provider for mainland China users
Cherry-picked from PR #7637 by hcshen0111.
Adds kimi-coding-cn provider with dedicated KIMI_CN_API_KEY env var
and api.moonshot.cn/v1 endpoint for China-region Moonshot users.
2026-04-13 11:20:37 -07:00
Teknium ef180880aa fix: guard anthropic_adapter import + use canonical authorize URL
- Wrap module-level import from agent.anthropic_adapter in try/except
  so hermes web still starts if the adapter is unavailable; Phase 2
  PKCE endpoints return 501 in that case.
- Change authorize URL from console.anthropic.com to claude.ai to
  match the canonical adapter code.
2026-04-13 11:18:18 -07:00
kshitijk4poor 247929b0dd feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.

## Backend (hermes_cli/web_server.py)

- 6 new API endpoints:
  GET /api/providers/oauth — list providers with connection status
  POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
  POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
  GET /api/providers/oauth/{id}/poll/{session} — poll device-code
  DELETE /api/providers/oauth/{id} — disconnect provider
  DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints

## Frontend

- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
2026-04-13 11:18:18 -07:00
yongtenglei 2773b18b56 fix(run_agent): refresh activity during streaming responses
Previously, long-running streamed responses could be incorrectly treated
as idle by the gateway/cron inactivity timeout even while tokens were
actively arriving. The _touch_activity() call (which feeds
get_activity_summary() polled by the external timeout) was either called
only on the first chunk (chat completions) or not at all (Anthropic,
Codex, Codex fallback).

Add _touch_activity() on every chunk/event in all four streaming paths
so the inactivity monitor knows data is still flowing.

Fixes #8760
2026-04-13 10:55:51 -07:00
Teknium ba50fa3035 docs: fix 30+ inaccuracies across documentation (#9023)
Cross-referenced all docs pages against the actual codebase and fixed:

Reference docs (cli-commands.md, slash-commands.md, profile-commands.md):
- Fix: hermes web -> hermes dashboard (correct subparser name)
- Fix: Wrong provider list (removed deepseek, ai-gateway, opencode-zen,
  opencode-go, alibaba; added gemini)
- Fix: Missing tts in hermes setup section choices
- Add: Missing --image flag for hermes chat
- Add: Missing --component flag for hermes logs
- Add: Missing CLI commands: debug, backup, import
- Fix: /status incorrectly marked as messaging-only (available everywhere)
- Fix: /statusbar moved from Session to Configuration category
- Add: Missing slash commands: /fast, /snapshot, /image, /debug
- Add: Missing /restart from messaging commands table
- Fix: /compress description to match COMMAND_REGISTRY
- Add: --no-alias flag to profile create docs

Configuration docs (configuration.md, environment-variables.md):
- Fix: Vision timeout default 30s -> 120s
- Fix: TTS providers missing minimax and mistral
- Fix: STT providers missing mistral
- Fix: TTS openai base_url shown with wrong default
- Fix: Compression config showing stale summary_model/provider/base_url
  keys (migrated out in config v17) -> target_ratio/protect_last_n

Getting-started docs:
- Fix: Redundant faster-whisper install (already in voice extra)
- Fix: Messaging extra description missing Slack

Developer guide:
- Fix: architecture.md tool count 48 -> 47, toolset count 40 -> 19
- Fix: run_agent.py line count 9,200 -> 10,700
- Fix: cli.py line count 8,500 -> 10,000
- Fix: main.py line count 5,500 -> 6,000
- Fix: gateway/run.py line count 7,500 -> 9,000
- Fix: Browser tools count 11 -> 10
- Fix: Platform adapter count 15 -> 18 (add wecom_callback, api_server)
- Fix: agent-loop.md wrong budget sharing (not shared, independent)
- Fix: agent-loop.md non-existent _get_budget_warning() reference
- Fix: context-compression-and-caching.md non-existent function name
- Fix: toolsets-reference.md safe toolset includes mixture_of_agents (it doesn't)
- Fix: toolsets-reference.md hermes-cli tool count 38 -> 36

Guides:
- Fix: automate-with-cron.md claims daily at 9am is valid (it's not)
- Fix: delegation-patterns.md Max 3 presented as hard cap (configurable)
- Fix: sessions.md group thread key format (shared by default, not per-user)
- Fix: cron-internals.md job ID format and JSON structure
2026-04-13 10:53:10 -07:00
Teknium 4ca6668daf docs: comprehensive update for recent merged PRs (#9019)
Audit and update documentation across 12 files to match changes from
~50 recently merged PRs. Key updates:

Slash commands (slash-commands.md):
- Add 5 missing commands: /snapshot, /fast, /image, /debug, /restart
- Fix /status incorrectly labeled as messaging-only (available in both)
- Add --global flag to /model docs
- Add [focus topic] arg to /compress docs

CLI commands (cli-commands.md):
- Add hermes debug share section with options and examples
- Add hermes backup section with --quick and --label flags
- Add hermes import section

Feature docs:
- TTS: document global tts.speed and per-provider speed for Edge/OpenAI
- Web dashboard: add docs for 5 missing pages (Sessions, Logs,
  Analytics, Cron, Skills) and 15+ API endpoints
- WhatsApp: add streaming, 4K chunking, and markdown formatting docs
- Skills: add GitHub rate-limit/GITHUB_TOKEN troubleshooting tip
- Budget: document CLI notification on iteration budget exhaustion

Config migration (compression.summary_* → auxiliary.compression.*):
- Update configuration.md, environment-variables.md,
  fallback-providers.md, cli.md, and context-compression-and-caching.md
- Replace legacy compression.summary_model/provider/base_url references
  with auxiliary.compression.model/provider/base_url
- Add legacy migration info boxes explaining auto-migration

Minor fixes:
- wecom-callback.md: clarify 'text only' limitation (input only)
- Escape {session_id}/{job_id} in web-dashboard.md headings for MDX
2026-04-13 10:50:59 -07:00
墨綠BG c449cd1af5 fix(config): restore custom providers after v11→v12 migration
The v11→v12 migration converts custom_providers (list) into providers
(dict), then deletes the list. But all runtime resolvers read from
custom_providers — after migration, named custom endpoints silently stop
resolving and fallback chains fail with AuthError.

Add get_compatible_custom_providers() that reads from both config schemas
(legacy custom_providers list + v12+ providers dict), normalizes entries,
deduplicates, and returns a unified list. Update ALL consumers:

- hermes_cli/runtime_provider.py: _get_named_custom_provider() + key_env
- hermes_cli/auth_commands.py: credential pool provider names
- hermes_cli/main.py: model picker + _model_flow_named_custom()
- agent/auxiliary_client.py: key_env + custom_entry model fallback
- agent/credential_pool.py: _iter_custom_providers()
- cli.py + gateway/run.py: /model switch custom_providers passthrough
- run_agent.py + gateway/run.py: per-model context_length lookup

Also: use config.pop() instead of del for safer migration, fix stale
_config_version assertions in tests, add pool mock to codex test.

Co-authored-by: 墨綠BG <s5460703@gmail.com>
Closes #8776, salvaged from PR #8814
2026-04-13 10:50:52 -07:00
Teknium 0dd26c9495 fix(tests): fix 78 CI test failures and remove dead test (#9036)
Production fixes:
- voice_mode.py: add is_recording property to AudioRecorder (parity with TermuxAudioRecorder)
- cronjob_tools.py: add sms example to deliver description

Test fixes:
- test_real_interrupt_subagent: add missing _execution_thread_id (fixes 19 cascading failures from leaked _build_system_prompt patch)
- test_anthropic_error_handling: add _FakeMessages, override _interruptible_streaming_api_call (6 fixes)
- test_ctx_halving_fix: add missing request_overrides attribute (4 fixes)
- test_context_token_tracking: set _disable_streaming=True for non-streaming test path (4 fixes)
- test_dict_tool_call_args: set _disable_streaming=True (1 fix)
- test_provider_parity: add model='gpt-4o' for AIGateway tests to meet 64K minimum context (4 fixes)
- test_session_race_guard: add user_id to SessionSource (5 fixes)
- test_restart_drain/helpers: add user_id to SessionSource (2 fixes)
- test_telegram_photo_interrupts: add user_id to SessionSource
- test_interrupt: target thread_id for per-thread interrupt system (2 fixes)
- test_zombie_process_cleanup: rewrite with object.__new__ for refactored GatewayRunner.stop() (1 fix)
- test_browser_camofox_state: update config version 15->17 (1 fix)
- test_trajectory_compressor_async: widen lookback window 10->20 for line-shifted AsyncOpenAI (1 fix)
- test_voice_mode: fixed by production is_recording addition (5 fixes)
- test_voice_cli_integration: add _attached_images to CLI stub (2 fixes)
- test_hermes_logging: explicit propagation/level reset for cross-test pollution defense (1 fix)
- test_run_agent: add base_url for OpenRouter detection tests (2 fixes)

Deleted:
- test_inline_think_blocks_reasoning_only_accepted: tested unimplemented inline <think> handling
2026-04-13 10:50:24 -07:00
kimsr96 b909a9efef fix: extend ASCII-locale UnicodeEncodeError recovery to full request payload
The existing ASCII codec handler only sanitized conversation messages,
leaving tool schemas, system prompts, ephemeral prompts, prefill messages,
and HTTP headers as unhandled sources of non-ASCII content. On systems
with LANG=C or non-UTF-8 locale, Unicode symbols in tool descriptions
(e.g. arrows, em-dashes from prompt_builder) and system prompt content
would cause UnicodeEncodeError that fell through to the error path.

Changes:
- Add _sanitize_structure_non_ascii() generic recursive walker for
  nested dict/list payloads
- Add _sanitize_tools_non_ascii() thin wrapper for tool schemas
- Add _force_ascii_payload flag: once ASCII locale is detected, all
  subsequent API calls get proactively sanitized (prevents recurring
  failures from new tool results bringing fresh Unicode each turn)
- Extend the ASCII codec error handler to sanitize: prefill_messages,
  tool schemas (self.tools), system prompt, ephemeral system prompt,
  and default HTTP headers
- Update stale comment that acknowledged the gap

Cherry-picked from PR #8834 (credential pool changes dropped as
separate concern).
2026-04-13 05:16:35 -07:00
Teknium 28a9c43f81 fix: resolve key_env to actual API key value instead of env var name
The cherry-picked code passed the env var NAME (e.g. 'MY_API_KEY') as the
api_key value. The caller's has_usable_secret() check would reject the
var name, so the actual key was never used. Now we os.getenv() the
key_env value to get the real API key before returning it.
2026-04-13 05:16:21 -07:00
Geoff 76eecf3819 fix(model): Support providers: dict for custom endpoints in /model
Two fixes for user-defined providers in config.yaml:

1. list_authenticated_providers() - now includes full models list from
   providers.*.models array, not just default_model. This fixes /model
   showing only one model when multiple are configured.

2. _get_named_custom_provider() - now checks providers: dict (new-style)
   in addition to custom_providers: list (legacy). This fixes credential
   resolution errors when switching models via /model command.

Both changes are backwards compatible with existing custom_providers list format.

Fixes: Only one model appears for custom providers in /model selection
2026-04-13 05:16:21 -07:00
konsisumer 311dac1971 fix(file_tools): block /private/etc writes on macOS symlink bypass
On macOS, /etc is a symlink to /private/etc, so os.path.realpath()
resolves /etc/hosts to /private/etc/hosts. The sensitive path check
only matched /etc/ prefixes against the resolved path, allowing
writes to system files on macOS.

- Add /private/etc/ and /private/var/ to _SENSITIVE_PATH_PREFIXES
- Check both realpath-resolved and normpath-normalized paths
- Add regression tests for macOS symlink bypass

Closes #8734
Co-authored-by: ElhamDevelopmentStudio (PR #8829)
2026-04-13 05:15:05 -07:00
Teknium 587eeb56b9 chore: remove duplicate dead _try_gh_cli_token / _gh_cli_candidates from auth.py
These functions were duplicated between auth.py and copilot_auth.py.
The auth.py copies had zero production callers — only copilot_auth.py's
versions are used. Redirect the test import to the live copy and update
monkeypatch targets accordingly.
2026-04-13 05:12:36 -07:00
HearthCore 2a9e50c104 fix(copilot): resolve GHE token poisoning when GITHUB_TOKEN is set
When GITHUB_TOKEN is present in the environment (e.g. for gh CLI or
GitHub Actions), two issues broke Copilot authentication against
GitHub Enterprise (GHE) instances:

1. The copilot provider had no base_url_env_var, so COPILOT_API_BASE_URL
   was silently ignored — requests always went to public GitHub.

2. `gh auth token` (the CLI fallback) treats GITHUB_TOKEN as an override
   and echoes it back instead of reading from its credential store
   (hosts.yml). This caused the same rejected token to be used even
   after env var priority correctly skipped it.

Fix:
- Add base_url_env_var="COPILOT_API_BASE_URL" to copilot ProviderConfig
- Strip GITHUB_TOKEN/GH_TOKEN from the subprocess env when calling
  `gh auth token` so it reads from hosts.yml
- Pass --hostname from COPILOT_GH_HOST when set so gh returns the
  GHE-specific OAuth token
2026-04-13 05:12:36 -07:00
luyao618 8ec1608642 fix(agent): propagate api_mode to vision provider resolution
resolve_vision_provider_client() computed resolved_api_mode from config
but never passed it to downstream resolve_provider_client() or
_get_cached_client() calls, causing custom providers with
api_mode: anthropic_messages to crash when used for vision tasks.

Also remove the for_vision special case in _normalize_aux_provider()
that incorrectly discarded named custom provider identifiers.

Fixes #8857

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:02:54 -07:00
Teknium e3ffe5b75f fix: remove legacy compression.summary_* config and env var fallbacks (#8992)
Remove the backward-compat code paths that read compression provider/model
settings from legacy config keys and env vars, which caused silent failures
when auto-detection resolved to incompatible backends.

What changed:
- Remove compression.summary_model, summary_provider, summary_base_url from
  DEFAULT_CONFIG and cli.py defaults
- Remove backward-compat block in _resolve_task_provider_model() that read
  from the legacy compression section
- Remove _get_auxiliary_provider() and _get_auxiliary_env_override() helper
  functions (AUXILIARY_*/CONTEXT_* env var readers)
- Remove env var fallback chain for per-task overrides
- Update hermes config show to read from auxiliary.compression
- Add config migration (v16→17) that moves non-empty legacy values to
  auxiliary.compression and strips the old keys
- Update example config and openclaw migration script
- Remove/update tests for deleted code paths

Compression model/provider is now configured exclusively via:
  auxiliary.compression.provider / auxiliary.compression.model

Closes #8923
2026-04-13 04:59:26 -07:00
WorldInnovationsDepartment c1809e85e7 fix(gateway): handle stale lock files in acquire_scoped_lock
Updated the acquire_scoped_lock function to treat empty or corrupt lock files as stale. This change ensures that if a lock file exists but is invalid, it will be removed to prevent issues with stale locks. Added tests to verify recovery from both empty and corrupt lock files.
2026-04-13 04:59:25 -07:00
Teknium 23f668d66e fix: extract Gemma 4 <thought> reasoning in _extract_reasoning() (#8991)
Add <thought>(.*?)</thought> to inline_patterns so Gemma 4
reasoning content is captured for /reasoning display, not just
stripped from visible output.


Closes #8891

Co-authored-by: RhushabhVaghela <rhushabhvaghela@users.noreply.github.com>
2026-04-13 04:59:06 -07:00
flobo3 d8a521092b fix(weixin): rename send_document parameter to match base class 2026-04-13 04:58:30 -07:00
Teknium a5bd56eae3 fix: eliminate provider hang dead zones in retry/timeout architecture (#8985)
Three targeted changes to close the gaps between retry layers that
caused users to experience 'No response from provider for 580s' and
'No activity for 15 minutes' despite having 5 layers of retry:

1. Remove non-streaming fallback from streaming path

   Previously, when all 3 stream retries exhausted, the code fell back
   to _interruptible_api_call() which had no stale detection and no
   activity tracking — a black hole that could hang for up to 1800s.
   Now errors propagate to the main retry loop which has richer recovery
   (credential rotation, provider fallback, backoff).

   For 'stream not supported' errors, sets _disable_streaming flag so
   the main retry loop automatically switches to non-streaming on the
   next attempt.

2. Add _touch_activity to recovery dead zones

   The gateway inactivity monitor relies on _touch_activity() to know
   the agent is alive, but activity was never touched during:
   - Stale stream detection/kill cycles (180-300s gaps)
   - Stream retry connection rebuilds
   - Main retry backoff sleeps (up to 120s)
   - Error recovery classification

   Now all these paths touch activity every ~30s, keeping the gateway
   informed during recovery cycles.

3. Add stale-call detector to non-streaming path

   _interruptible_api_call() now has the same stale detection pattern
   as the streaming path: kills hung connections after 300s (default,
   configurable via HERMES_API_CALL_STALE_TIMEOUT), scaled for large
   contexts (450s for 50K+ tokens, 600s for 100K+ tokens), disabled
   for local providers.

   Also touches activity every ~30s during the wait so the gateway
   monitor stays informed.

Env vars:
- HERMES_API_CALL_STALE_TIMEOUT: non-streaming stale timeout (default 300s)
- HERMES_STREAM_STALE_TIMEOUT: unchanged (default 180s)

Before: worst case ~2+ hours of sequential retries with no feedback
After: worst case bounded by gateway inactivity timeout (default 1800s)
with continuous activity reporting
2026-04-13 04:55:20 -07:00
Teknium acdff020b7 test: add multi-word query tests for truncation match strategy
Tests phrase matching, proximity co-occurrence, and sliding window
coverage maximisation — the three new tiers from the truncation fix.
2026-04-13 04:54:42 -07:00
Al Sayed Hoota a5bc698b9a fix(session_search): improve truncation to center on actual query matches
Three-tier match strategy for _truncate_around_matches():
1. Full-phrase search (exact query string positions)
2. Proximity co-occurrence (all terms within 200 chars)
3. Individual terms (fallback, preserves existing behavior)

Sliding window picks the start offset covering the most matches.

Moved inline import re to module level.

Co-authored-by: Al Sayed Hoota <78100282+AlsayedHoota@users.noreply.github.com>
2026-04-13 04:54:42 -07:00
landy dbed40f39b fix: reopen resumed gateway sessions in sqlite 2026-04-13 04:54:07 -07:00
flobo3 d945cf6b1a fix(docker): add .venv to .dockerignore 2026-04-13 04:52:00 -07:00
twilwa 3a64348772 fix(discord): voice session continuity and signal handler thread safety
- Store source metadata on /voice channel join so voice input shares the
  same session as the linked text channel conversation
- Treat voice-linked text channels as free-response (skip @mention and
  auto-thread) while voice is active
- Scope the voice-linked exemption to the exact bound channel, not
  sibling threads
- Guard signal handler registration in start_gateway() for non-main
  threads (prevents RuntimeError when gateway runs in a daemon thread)
- Clean up _voice_sources on leave_voice_channel

Salvaged from PR #3475 by twilwa (Modal runtime portions excluded).
2026-04-13 04:49:21 -07:00
Teknium 381810ad50 feat: fix SQLite safety in hermes backup + add --quick snapshots + /snapshot command (#8971)
Three changes consolidated into the existing backup system:

1. Fix: hermes backup now uses sqlite3.Connection.backup() for .db files
   instead of raw file copy. Raw copy of a WAL-mode database can produce
   a corrupted backup — the backup() API handles this correctly.

2. hermes backup --quick: fast snapshot of just critical state files
   (config.yaml, state.db, .env, auth.json, cron/jobs.json, etc.)
   stored in ~/.hermes/state-snapshots/. Auto-prunes to 20 snapshots.

3. /snapshot slash command (alias /snap): in-session interface for
   quick state snapshots. create/list/restore/prune subcommands.
   Restore by ID or number. Powered by the same backup module.

No new modules — everything lives in hermes_cli/backup.py alongside
the existing full backup/import code.

No hooks in run_agent.py — purely on-demand, zero runtime overhead.

Closes the use case from PRs #8406 and #7813 with ~200 lines of new
logic instead of a 1090-line content-addressed storage engine.
2026-04-13 04:46:13 -07:00
Richard Li 82901695ff feat(wecom): add platform hint for native media sending 2026-04-13 04:46:04 -07:00
Teknium 3365abdddf fix: use correct 'completed' state in status badge map, clean up blank lines
The cron backend uses 'completed' (not 'exhausted') when repeat count
is reached. Also removes extra blank lines from cherry-pick.
2026-04-13 04:45:29 -07:00
jonny 70f490a12a fix(web): CronPage crash when rendering schedule object
The cron API returns schedule as {kind, expr, display} object but
CronPage.tsx rendered it directly as a React child, crashing with
'Objects are not valid as a React child'.

- Update CronJob interface in api.ts to match actual API response
- Use schedule_display (string) instead of schedule (object)
- Use state instead of status for job state
- Use last_error instead of error for error display
2026-04-13 04:45:29 -07:00
Teknium 8dfee98d06 fix: clean up description escaping, add string-data tests
Follow-up for cherry-picked PR #8918.
2026-04-13 04:45:07 -07:00
dippwho bca22f3090 fix(homeassistant): #8912 resolve XML tool calling loop by casting nested object to JSON string 2026-04-13 04:45:07 -07:00
MaybeRichard 11e2e04667 fix(telegram): pass proxy URL explicitly to HTTPXRequest when proxy env vars are set
When HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars are set (or macOS system proxy
is detected), pass the proxy URL explicitly via HTTPXRequest(proxy=proxy_url) instead
of relying on httpx's trust_env mechanism, which is unreliable for HTTP CONNECT
proxies (e.g. Clash / ClashMac in fake-ip mode).

Uses the shared resolve_proxy_url() from base.py (handles env vars + macOS system
proxy detection) instead of duplicating env var reading inline. Consolidates the
proxy_configured boolean into a single proxy_url = resolve_proxy_url() call that
serves as both the gate for skipping fallback-IP transport and the value passed
to HTTPXRequest.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
Salvaged from PR #8931 by MaybeRichard.
2026-04-13 04:45:05 -07:00
XiaoXiao0221 860489600a fix(cli): sanitize surrogate characters in handle_paste
Prevents UTF-8 encoding crash when pasting text from Word or Google Docs,
which may contain lone surrogate code points (U+D800-U+DFFF).
Reuses existing _sanitize_surrogates() from run_agent module.
2026-04-13 04:42:45 -07:00
Teknium 0998a57007 refactor: remove 5 dead utility functions from utils.py (#8975)
Remove read_json_file, read_jsonl, append_jsonl, env_str, env_lower —
all added in #7917 but never imported anywhere in the codebase. Also
remove unused List and Optional typing imports.

env_int, env_bool, and the other helpers that have real consumers are
kept.
2026-04-13 04:39:59 -07:00
Teknium cea34dc7ef fix: follow-up for salvaged PR #8939
- Move test file to tests/hermes_cli/ (consistent with test layout)
- Remove unused imports (os, pytest) from test file
- Update _sanitize_env_lines docstring: now used on read + write paths
2026-04-13 04:35:37 -07:00
Mil Wang (from Dev Box) e469f3f3db fix: sanitize .env before loading to prevent token duplication (#8908)
When .env files become corrupted (e.g. concatenated KEY=VALUE pairs on
a single line due to concurrent writes or encoding issues), both
python-dotenv and load_env() would parse the entire concatenated string
as a single value. This caused bot tokens to appear duplicated up to 8×,
triggering InvalidToken errors from the Telegram API.

Root cause: _sanitize_env_lines() — which correctly splits concatenated
lines — was only called during save_env_value() writes, not during reads.

Fix:
- load_env() now calls _sanitize_env_lines() before parsing
- env_loader.load_hermes_dotenv() sanitizes the .env file on disk
  before python-dotenv reads it, so os.getenv() also returns clean values
- Added tests reproducing the exact corruption pattern from #8908

Closes #8908
2026-04-13 04:35:37 -07:00
ismell0992-afk e77f135ed8 fix(cli): narrow Nous Hermes non-agentic warning to actual hermes-3/-4 models
The startup warning that Nous Research Hermes 3 & 4 models are not agentic
fired on any model whose name contained "hermes" anywhere, via a plain
substring check. That false-positived on unrelated local Modelfiles such
as `hermes-brain:qwen3-14b-ctx16k` — a tool-capable Qwen3 wrapper that
happens to live under a custom "hermes" tag namespace — making the warning
noise for legitimate setups.

Replace the substring check with a narrow regex anchored on `^`, `/`, or
`:` boundaries that only matches the real Hermes-3 / Hermes-4 chat family
(e.g. `NousResearch/Hermes-3-Llama-3.1-70B`, `hermes-4-405b`,
`openrouter/hermes3:70b`). Consolidate into a single helper
`is_nous_hermes_non_agentic()` in `hermes_cli.model_switch` so the CLI
and the canonical check don't drift, and route the duplicate inline site
in `cli.HermesCLI._print_warnings()` through the helper.

Add a parametrized test covering positive matches (real Hermes-3/-4
names) and a broad set of negatives (custom Modelfiles, Qwen/Claude/GPT,
older Nous-Hermes-2 families, bare "hermes", empty string, and the
"brain-hermes-3-impostor" boundary case).
2026-04-13 04:33:52 -07:00
ismell0992-afk 3e99964789 fix(agent): prefer Ollama Modelfile num_ctx over GGUF training max
_query_local_context_length was checking model_info.context_length
(the GGUF training max) before num_ctx (the Modelfile runtime override),
inverse to query_ollama_num_ctx. The two helpers therefore disagreed on
the same model:

  hermes-brain:qwen3-14b-ctx32k     # Modelfile: num_ctx 32768
  underlying qwen3:14b GGUF         # qwen3.context_length: 40960

query_ollama_num_ctx correctly returned 32768 (the value Ollama will
actually allocate KV cache for). _query_local_context_length returned
40960, which let ContextCompressor grow conversations past 32768 before
triggering compression — at which point Ollama silently truncated the
prefix, corrupting context.

Swap the order so num_ctx is checked first, matching query_ollama_num_ctx.
Adds a parametrized test that seeds both values and asserts num_ctx wins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 04:24:07 -07:00
Teknium 39b83f3443 fix: remove sandbox language from tool descriptions
The terminal and execute_code tool schemas unconditionally mentioned
'cloud sandboxes' in their descriptions sent to the model. This caused
agents running on local backends to believe they were in a sandboxed
environment, refusing networking tasks and other operations. Worse,
agents sometimes saved this false belief to persistent memory, making
it persist across sessions.

Reported by multiple users (XLion, 林泽).
2026-04-13 04:23:27 -07:00
Teknium 67fece1176 feat(cli): show notification when iteration budget is reached
Displays a dim warning after the response panel when the agent hit
its max iterations, so the user knows the response may be incomplete.
2026-04-13 03:40:47 -07:00
Teknium 934318ba3a fix: budget-exhausted conversations now get a summary instead of empty response
The post-loop grace call mechanism was broken: it injected a user
message and set _budget_grace_call=True, but could never re-enter the
while loop (already exited).  Worse, the flag blocked the fallback
_handle_max_iterations from running, so final_response stayed None.

Users saw empty/no response when the agent hit max iterations.

Fix: remove the dead grace block and let _handle_max_iterations handle
it directly — it already injects a summary request and makes one extra
toolless API call.
2026-04-13 03:36:20 -07:00
Teknium 3804556cd9 fix: restore clarify toolset row removed in cherry-pick 2026-04-13 02:49:11 -07:00
Haoqing Wang 8e0ae66520 fix(skills): correct TTS/STT providers, add missing platforms/commands in hermes-agent skill
Fixes verified via 5-container parallel testing against v0.8.0 codebase.

Critical fixes:
- TTS providers: replace nonexistent kokoro/fish with actual minimax/mistral/neutts
- STT providers: add missing mistral (Voxtral Transcribe)
- Testing section: remove `source venv/bin/activate` (no venv dir in project)

Expanded coverage:
- Provider table: 13 → 22 entries (add Gemini, xAI, Xiaomi, Qwen OAuth, MiniMax CN, etc.)
- Platform list: add BlueBubbles (iMessage) and Weixin (WeChat), clarify Open WebUI
- Slash commands: add 14 undocumented commands (/approve, /deny, /branch, /fast, etc.)
- Toolsets: add 4 missing (messaging, search, todo, rl)
- Troubleshooting: expand from 6 to 10 sections with practical deployment fixes
  (Copilot OAuth 403, gateway linger, WSL2 systemd, Discord intents, etc.)

Minor fixes:
- agent/ directory description expanded
- delegation config keys completed
- /restart noted as gateway-only
- hermes honcho noted as plugin-dependent
2026-04-13 02:49:11 -07:00
Teknium 397eae5d93 fix: recover partial streamed content on connection failure
When streaming fails after partial content delivery (e.g. OpenRouter
timeout kills connection mid-response), the stub response now carries
the accumulated streamed text instead of content=None.

Two fixes:
1. The partial-stream stub response includes recovered content from
   _current_streamed_assistant_text — the text that was already
   delivered to the user via stream callbacks before the connection
   died.

2. The empty response recovery chain now checks for partial stream
   content BEFORE falling back to _last_content_with_tools (prior
   turn content) or wasting API calls on retries. This prevents:
   - Showing wrong content from a prior turn
   - Burning 3+ unnecessary retry API calls
   - Falling through to '(empty)' when the user already saw content

The root cause: OpenRouter has a ~125s inactivity timeout. When
Anthropic's SSE stream goes silent during extended reasoning, the
proxy kills the connection. The model's text was already partially
streamed but the stub discarded it, triggering the empty recovery
chain which would show stale prior-turn content or waste retries.
2026-04-13 02:12:01 -07:00
Teknium 35b11f48a5 docs: add web dashboard documentation (#8864)
- New docs page: user-guide/features/web-dashboard.md covering
  quick start, prerequisites, all three pages (Status, Config, API Keys),
  the /reload slash command, REST API endpoints, CORS config, and
  development workflow
- Added 'Management' category in sidebar for web-dashboard
- Added 'hermes web' to CLI commands reference with options table
- Added '/reload' to slash commands reference (both CLI and gateway tables)
2026-04-13 01:15:27 -07:00
Ubuntu 73ed09e145 fix(gateway): keep venv python symlink unresolved when remapping paths
_remap_path_for_user was calling .resolve() on the Python path, which
followed venv/bin/python into the base interpreter. On uv-managed venvs
this swaps the systemd ExecStart to a bare Python that has none of the
venv's site-packages, so the service crashes on first import. Classical
python -m venv installs were unaffected by accident: the resolved target
/usr/bin/python3.x lives outside $HOME so the path-remap branch was
skipped and the system Python's packages silently worked.

Remove .resolve() calls on both current_home and the path; use
.expanduser() for lexical tilde expansion only. The function does
lexical prefix substitution, which is all it needs to do for its
actual purpose (remapping /root/.hermes -> /home/<user>/.hermes when
installing system services as root for a different user).

Repro: on a uv-managed venv install, `sudo hermes gateway install
--system` writes ExecStart=.../uv/python/cpython-3.11.15-.../bin/python3.11
instead of .../hermes-agent/venv/bin/python, and the service crashes on
ModuleNotFoundError: yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:49:22 -07:00
Teknium 964ef681cf fix(gateway): improve /restart response with fallback instructions 2026-04-12 22:34:23 -07:00
Teknium 276d20e62c fix(gateway): /restart uses service restart under systemd instead of detached subprocess
The detached bash subprocess spawned by /restart gets killed by
systemd's KillMode=mixed cgroup cleanup, leaving the gateway dead.

Under systemd (detected via INVOCATION_ID env var), /restart now uses
via_service=True which exits with code 75 — RestartForceExitStatus=75
in the unit file makes systemd auto-restart the service. The detached
subprocess approach is preserved as fallback for non-systemd
environments (Docker, tmux, foreground mode).
2026-04-12 22:32:19 -07:00
Teknium e2a9b5369f feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)

Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages

Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available

Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs

Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly

Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.

Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621#8204), re-salvaged onto current main with stale-branch
regressions removed.

* fix(web): clean up status page cards, always rebuild on `hermes web`

- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
  preventing stale web_dist when editing frontend files

* feat(web): full-text search across session messages

- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset

---------

Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
Dusk1e c052cf0eea fix(security): validate domain/service params in ha_call_service to prevent path traversal 2026-04-12 22:26:15 -07:00
Teknium 8a64f3e368 feat(gateway): notify /restart requester when gateway comes back online
When a user sends /restart, the gateway now persists their routing info
(platform, chat_id, thread_id) to .restart_notify.json. After the new
gateway process starts and adapters connect, it reads the file, sends a
'Gateway restarted successfully' message to that specific chat, and
cleans up the file.

This follows the same pattern as _send_update_notification (used by
/update). Thread IDs are preserved so the notification lands in the
correct Telegram topic or Discord thread.

Previously, after /restart the user had no feedback that the gateway was
back — they had to send a message to find out. Now they get a proactive
notification and know their session continues.
2026-04-12 22:23:48 -07:00
Teknium b22663ea69 docs: restore Orchestra Research attribution in research-paper-writing skill (#8800)
PR #4654 replaced ml-paper-writing with research-paper-writing, preserving
the writing philosophy and reference files but dropping the dedicated
'Sources Behind This Guidance' attribution table from the SKILL.md body.

Re-adds:
- The researcher attribution table (Nanda, Farquhar, Gopen & Swan, Lipton,
  Steinhardt, Perez, Karpathy) with affiliations and links to SKILL.md
- Orchestra Research credit as original compiler of the writing philosophy
- 'Origin & Attribution' section in sources.md documenting the full chain:
  Nanda blog → Orchestra skill → teknium integration → SHL0MS expansion
2026-04-12 22:03:18 -07:00
Teknium 83ca0844f7 fix: preserve dots in model names for OpenCode Zen and ZAI providers (#8794)
OpenCode Zen was in _DOT_TO_HYPHEN_PROVIDERS, causing all dotted model
names (minimax-m2.5-free, gpt-5.4, glm-5.1) to be mangled. The fix:

Layer 1 (model_normalize.py): Remove opencode-zen from the blanket
dot-to-hyphen set. Add an explicit block that preserves dots for
non-Claude models while keeping Claude hyphenated (Zen's Claude
endpoint uses anthropic_messages mode which expects hyphens).

Layer 2 (run_agent.py _anthropic_preserve_dots): Add opencode-zen and
zai to the provider allowlist. Broaden URL check from opencode.ai/zen/go
to opencode.ai/zen/ to cover both Go and Zen endpoints. Add bigmodel.cn
for ZAI URL detection.

Also adds glm-5.1 to ZAI model lists in models.py and setup.py.

Closes #7710

Salvaged from contributions by:
- konsisumer (PR #7739, #7719)
- DomGrieco (PR #8708)
- Esashiero (PR #7296)
- sharziki (PR #7497)
- XiaoYingGee (PR #8750)
- APTX4869-maker (PR #8752)
- kagura-agent (PR #7157)
2026-04-12 21:22:59 -07:00
Teknium a0cd2c5338 fix(gateway): verbose tool progress no longer truncates args when tool_preview_length is 0 (#8735)
When tool_preview_length is 0 (default for platforms without a tier
default, like Session), verbose mode was truncating args JSON to 200
characters.  Since the user explicitly opted into verbose mode, they
expect full tool call detail — the 200-char cap defeated the purpose.

Now: tool_preview_length=0 means no truncation in verbose mode.
Positive values still cap as before.  Platform message-length limits
handle overflow naturally.
2026-04-12 20:05:12 -07:00
Teknium 3636f64540 fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge (#8745)
* fix(telegram): use UTF-16 code units for message length splitting

Port from nearai/ironclaw#2304: Telegram's 4096 character limit is
measured in UTF-16 code units, not Unicode codepoints. Characters
outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B,
musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units.

Previously, truncate_message() used Python's len() which counts
codepoints. This could produce chunks exceeding Telegram's actual limit
when messages contain many astral-plane characters.

Changes:
- Add utf16_len() helper and _prefix_within_utf16_limit() for
  UTF-16-aware string measurement and truncation
- Add _custom_unit_to_cp() binary-search helper that maps a custom-unit
  budget to the largest safe codepoint slice position
- Update truncate_message() to accept optional len_fn parameter
- Telegram adapter now passes len_fn=utf16_len when splitting messages
- Fix fallback truncation in Telegram error handler to use
  _prefix_within_utf16_limit instead of codepoint slicing
- Update send_message_tool.py to use utf16_len for Telegram platform
- Add comprehensive tests: utf16_len, _prefix_within_utf16_limit,
  truncate_message with len_fn (emoji splitting, content preservation,
  code block handling)
- Update mock lambdas in reply_mode tests to accept **kw for len_fn

* fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge

Browser tools (agent-browser):
- Override lodash to 4.18.1 (fixes prototype pollution CVEs in transitive
  dep via node-simctl → @appium/logger). Not reachable in Hermes's code
  path but cleans the audit report.
- basic-ftp and brace-expansion updated via npm audit fix.

WhatsApp bridge:
- file-type updated (fixes infinite loop in ASF parser + ZIP bomb DoS)
- music-metadata updated (fixes infinite loop in ASF parser)
- path-to-regexp updated (fixes ReDoS, mitigated by localhost binding)

Both components now report 0 npm vulnerabilities.

Ref: https://gist.github.com/jacklevin74/b41b710d3e20ba78fb7e2d42e2b83819
2026-04-12 19:38:20 -07:00
Teknium 15b1a3aa69 fix: improve WhatsApp UX — chunking, formatting, streaming (#8723)
Three changes that address the poor WhatsApp experience reported by users:

1. Reclassify WhatsApp from TIER_LOW to TIER_MEDIUM in display_config.py
   — enables streaming and tool progress via the existing Baileys /edit
   bridge endpoint. Users now see progressive responses instead of
   minutes of silence followed by a wall of text.

2. Lower MAX_MESSAGE_LENGTH from 65536 to 4096 and add proper chunking
   — send() now calls format_message() and truncate_message() before
   sending, then loops through chunks with a small delay between them.
   The base class truncate_message() already handles code block boundary
   detection (closes/reopens fences at chunk boundaries). reply_to is
   only set on the first chunk.

3. Override format_message() with WhatsApp-specific markdown conversion
   — converts **bold** to *bold*, ~~strike~~ to ~strike~, headers to
   bold text, and [links](url) to text (url). Code blocks and inline
   code are protected from conversion via placeholder substitution.

Together these fix the two user complaints:
- 'sends the whole code all the time' → now chunked at 4K with proper
  formatting
- 'terminal gets interrupted and gets cooked' → streaming + tool progress
  give visual feedback so users don't accidentally interrupt with
  follow-up messages
2026-04-12 19:20:13 -07:00
Teknium 5fae356a85 fix: show full last assistant response when resuming a session (#8724)
When resuming a session with --resume or -c, the last assistant response
was truncated to 200 chars / 3 lines just like older messages in the recap.
This forced users to waste tokens re-asking for the response.

Now the last assistant message in the recap is shown in full with non-dim
styling, so users can see exactly where they left off. Earlier messages
remain truncated for compact display.

Changes:
- Track un-truncated text for the last assistant entry during collection
- Replace last entry with full text after history trimming
- Render last assistant entry with bold (non-dim) styling
- Update existing truncation tests to use multi-message histories
- Add new tests for full last response display (char + multiline)
2026-04-12 19:07:14 -07:00
Teknium 9e992df8ae fix(telegram): use UTF-16 code units for message length splitting (#8725)
Port from nearai/ironclaw#2304: Telegram's 4096 character limit is
measured in UTF-16 code units, not Unicode codepoints. Characters
outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B,
musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units.

Previously, truncate_message() used Python's len() which counts
codepoints. This could produce chunks exceeding Telegram's actual limit
when messages contain many astral-plane characters.

Changes:
- Add utf16_len() helper and _prefix_within_utf16_limit() for
  UTF-16-aware string measurement and truncation
- Add _custom_unit_to_cp() binary-search helper that maps a custom-unit
  budget to the largest safe codepoint slice position
- Update truncate_message() to accept optional len_fn parameter
- Telegram adapter now passes len_fn=utf16_len when splitting messages
- Fix fallback truncation in Telegram error handler to use
  _prefix_within_utf16_limit instead of codepoint slicing
- Update send_message_tool.py to use utf16_len for Telegram platform
- Add comprehensive tests: utf16_len, _prefix_within_utf16_limit,
  truncate_message with len_fn (emoji splitting, content preservation,
  code block handling)
- Update mock lambdas in reply_mode tests to accept **kw for len_fn
2026-04-12 19:06:20 -07:00
Teknium 3cd6cbee5f feat: add /debug slash command for all platforms
Adds /debug as a slash command available in CLI, Telegram, Discord,
Slack, and all other gateway platforms. Uploads debug report + full
logs to paste services and returns shareable URLs.

- commands.py: CommandDef in Info category (no cli_only/gateway_only)
- gateway/run.py: async handler with run_in_executor for blocking I/O
- cli.py: dispatch in process_command to run_debug_share
2026-04-12 18:08:45 -07:00
Teknium f724079d3b fix(gateway): reject known-weak placeholder credentials at startup
Port from openclaw/openclaw#64586: users who copy .env.example without
changing placeholder values now get a clear error at startup instead of
a confusing auth failure from the platform API. Also rejects placeholder
API_SERVER_KEY when binding to a network-accessible address.

Cherry-picked from PR #8677.
2026-04-12 18:05:41 -07:00
Teknium c7d8d109ff fix(matrix): trust m.mentions.user_ids as authoritative mention signal
Port from openclaw/openclaw#64796: Per MSC3952 / Matrix v1.7, the
m.mentions.user_ids field is the authoritative mention signal. Clients
that populate m.mentions but don't duplicate @bot in the body text
were being silently dropped when MATRIX_REQUIRE_MENTION=true.

Cherry-picked from PR #8673.
2026-04-12 18:05:41 -07:00
Teknium 88a12af58c feat: add hermes debug share — upload debug report to pastebin (#8681)
* feat: add `hermes debug share` — upload debug report to pastebin

Adds a new `hermes debug share` command that collects system info
(via hermes dump), recent logs (agent.log, errors.log, gateway.log),
and uploads the combined report to a paste service (paste.rs primary,
dpaste.com fallback). Returns a shareable URL for support.

Options:
  --lines N    Number of log lines per file (default: 200)
  --expire N   Paste expiry in days (default: 7, dpaste.com only)
  --local      Print report locally without uploading

Files:
  hermes_cli/debug.py           - New module: paste upload + report collection
  hermes_cli/main.py            - Wire cmd_debug + argparse subparser
  tests/hermes_cli/test_debug.py - 19 tests covering upload, collection, CLI

* feat: upload full agent.log and gateway.log as separate pastes

hermes debug share now uploads up to 3 pastes:
  1. Summary report (system info + log tails) — always
  2. Full agent.log (last ~500KB) — if file exists
  3. Full gateway.log (last ~500KB) — if file exists

Each paste uploads independently; log upload failures are noted
but don't block the main report. Output shows all links aligned:

  Report     https://paste.rs/abc
  agent.log  https://paste.rs/def
  gateway.log https://paste.rs/ghi

Also adds _read_full_log() with size-capped tail reading to stay
within paste service limits (~512KB per file).

* feat: prepend hermes dump to each log paste for self-contained context

Each paste (agent.log, gateway.log) now starts with the hermes dump
output so clicking any single link gives full system context without
needing to cross-reference the summary report.

Refactored dump capture into _capture_dump() — called once and
reused across the summary report and each log paste.

* fix: fall back to .1 rotated log when primary log is missing or empty

When gateway.log (or agent.log) doesn't exist or is empty, the debug
share now checks for the .1 rotation file. This is common — the
gateway rotates logs and the primary file may not exist yet.

Extracted _resolve_log_path() to centralize the fallback logic for
both _read_log_tail() and _read_full_log().

* chore: remove unused display_hermes_home import
2026-04-12 18:05:14 -07:00
Teknium bcad679799 fix(api_server): normalize array-based content parts in chat completions
Some OpenAI-compatible clients (Open WebUI, LobeChat, etc.) send
message content as an array of typed parts instead of a plain string:

    [{"type": "text", "text": "hello"}]

The agent pipeline expects strings, so these array payloads caused
silent failures or empty messages.

Add _normalize_chat_content() with defensive limits (recursion depth,
list size, output length) and apply it to both the Chat Completions
and Responses API endpoints. The Responses path had inline
normalization that only handled input_text/output_text — the shared
function also handles the standard 'text' type.

Salvaged from PR #7980 (ikelvingo) — only the content normalization;
the SSE and Weixin changes in that PR were regressions and are not
included.

Co-authored-by: ikelvingo <ikelvingo@users.noreply.github.com>
2026-04-12 18:03:16 -07:00
AaronWong1999 e8385f6f89 docs: add HermesClaw to community ecosystem
Adds a one-line entry for HermesClaw (community WeChat bridge) to the Community section. It lets users run Hermes Agent and OpenClaw on the same WeChat account.
2026-04-12 18:03:16 -07:00
Sicheng Li ea2829ab43 fix(weixin,wecom,matrix): respect system proxy via aiohttp trust_env
aiohttp.ClientSession defaults to trust_env=False, ignoring HTTP_PROXY/
HTTPS_PROXY env vars. This causes QR login and all API calls to fail for
users behind a proxy (e.g. Clash in fake-ip mode), which is common in
China where Weixin and WeCom are primarily used.

Added trust_env=True to all aiohttp.ClientSession instantiations that
connect to external hosts (weixin: 3 places, wecom: 1, matrix: 1).
WhatsApp sessions are excluded as they only connect to localhost.

httpx-based adapters (dingtalk, signal, wecom_callback) are unaffected
as httpx defaults to trust_env=True.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:03:16 -07:00
Teknium bc4e2744c3 test: add tests for compression config_context_length passthrough
- Test that auxiliary.compression.context_length from config is forwarded
  to get_model_context_length (positive case)
- Test that invalid/non-integer config values are silently ignored
- Fix _make_agent() to set config=None (cherry-picked code reads self.config)
2026-04-12 17:52:34 -07:00
ygd58 4a9c356559 fix(compression): pass configured context_length to feasibility check
_check_compression_model_feasibility() called get_model_context_length()
without passing config_context_length, so custom endpoints that do not
support /models API queries always fell through to the 128K default,
ignoring auxiliary.compression.context_length in config.yaml.

Fix: read auxiliary.compression.context_length from config and pass it
as config_context_length (highest-priority hint) so the user-configured
value is always respected regardless of API availability.

Fixes #8499
2026-04-12 17:52:34 -07:00
Teknium 0d0d27d45e test(tts): add speed config tests for Edge, OpenAI, and MiniMax
12 tests covering:
- Provider-specific speed overrides global speed
- Global speed used as fallback
- Default (no speed) preserves existing behavior
- Edge SSML rate string conversion (positive/negative)
- OpenAI speed clamping to 0.25-4.0 range
2026-04-12 16:46:18 -07:00
0xbyt4 8ec0656f53 feat(tts): add speed support for Edge TTS and OpenAI TTS
Read tts.speed (global) or tts.<provider>.speed (provider-specific) from
config. Provider-specific takes precedence over global.

- Edge TTS: converts speed float to SSML prosody rate string
- OpenAI TTS: passes speed param clamped to 0.25-4.0
- MiniMax: wired into global tts.speed fallback for consistency

Co-authored-by: 0xbyt4 <0xbyt4@users.noreply.github.com>
2026-04-12 16:46:18 -07:00
Teknium 651419b014 fix: make mimo-v2-pro the default model for Nous portal users
Users who set up Nous auth without explicitly selecting a model via
`hermes model` were silently falling back to anthropic/claude-opus-4.6
(the first entry in _PROVIDER_MODELS['nous']), causing unexpected
charges on their Nous plan. Move xiaomi/mimo-v2-pro to the first
position so unconfigured users default to a free model instead.
2026-04-12 16:44:03 -07:00
Teknium a266238e1e fix(weixin): streaming cursor, media uploads, markdown links, blank messages (#8665)
Four fixes for the Weixin/WeChat adapter, synthesized from the best
aspects of community PRs #8407, #8521, #8360, #7695, #8308, #8525,
#7531, #8144, #8251.

1. Streaming cursor (▉) stuck permanently — WeChat doesn't support
   message editing, so the cursor appended during streaming can never
   be removed.  Add SUPPORTS_MESSAGE_EDITING = False to WeixinAdapter
   and check it in gateway/run.py to use an empty cursor for non-edit
   platforms.  (Fixes #8307, #8326)

2. Media upload failures — two bugs in _send_file():
   a) upload_full_url path used PUT (404 on WeChat CDN); now uses POST.
   b) aes_key was base64(raw_bytes) but the iLink API expects
      base64(hex_string); images showed as grey boxes.  (Fixes #8352, #7529)
   Also: unified both upload paths into _upload_ciphertext(), preferring
   upload_full_url.  Added send_video/send_voice methods and voice_item
   media builder for audio/.silk files.  Added video_md5 field.

3. Markdown links stripped — WeChat can't render [text](url), so
   format_message() now converts them to 'text (url)' plaintext.
   Code blocks are preserved.  (Fixes #7617)

4. Blank message prevention — three guards:
   a) _split_text_for_weixin_delivery('') returns [] not ['']
   b) send() filters empty/whitespace chunks before _send_text_chunk
   c) _send_message() raises ValueError for empty text as safety net

Community credit: joei4cm (#8407), lyonDan (#8521), SKFDJKLDG (#8360),
tomqiaozc (#7695), joshleeeeee (#8308), luoxiao6645(#8525),
longsizhuo (#7531), Astral-Yang (#8144), QingWei-Li (#8251).
2026-04-12 16:43:25 -07:00
Teknium c83674dd77 fix: unify OpenClaw detection, add isatty guard, fix print_warning import
Combines detection from both PRs into _detect_openclaw_processes():
- Cross-platform process scan (pgrep/tasklist/PowerShell) from PR #8102
- systemd service check from PR #8555
- Returns list[str] with details about what's found

Fixes in cleanup warning (from PR #8555):
- print_warning -> print_error/print_info (print_warning not in import chain)
- Added isatty() guard for non-interactive sessions
- Removed duplicate _check_openclaw_running() in favor of shared function

Updated all tests to match new API.
2026-04-12 16:40:37 -07:00
Serhat Dolmac 76f7411fca fix(claw): warn and prompt if OpenClaw is still running before archival (fixes #8502) 2026-04-12 16:40:37 -07:00
dirtyfancy 9fb36738a7 fix(claw): address Copilot review on Windows detection and non-interactive prompt
- Use PowerShell to inspect node.exe command lines on Windows,
  since tasklist output does not include them.
- Also check for dedicated openclaw.exe/clawd.exe processes.
- Skip the interactive prompt in non-interactive sessions so the
  preview-only behavior is preserved.
- Update tests accordingly.

Relates to #7907
2026-04-12 16:40:37 -07:00
dirtyfancy 5af9614f6d fix(claw): warn if OpenClaw is running before migration
Add _is_openclaw_running() and _warn_if_openclaw_running() to detect
OpenClaw processes (via pgrep/tasklist) before hermes claw migrate.
Warns the user that messaging platforms only allow one active session
per bot token, and lets them cancel or continue.

Fixes #7907
2026-04-12 16:40:37 -07:00
Teknium 76019320fb feat(skills): centralized skills index — eliminate GitHub API calls for search/install
Add a CI-built skills index served from the docs site. The index is
crawled daily by GitHub Actions, resolves all GitHub paths upfront, and
is cached locally by the client. When the index is available:

- Search uses the cached index (0 GitHub API calls, was 23+)
- Install uses resolved paths from index (6 API calls for file
  downloads only, was 31-45 for discovery + downloads)

Total: 68 → 6 GitHub API calls for a typical search + install flow.
Unauthenticated users (60 req/hr) can now search and install without
hitting rate limits.

Components:
- scripts/build_skills_index.py: Crawl all sources (skills.sh, GitHub
  taps, official, clawhub, lobehub), batch-resolve GitHub paths via
  tree API, output JSON index
- tools/skills_hub.py: HermesIndexSource class — search/fetch/inspect
  backed by the index, with lazy GitHubSource for file downloads
- parallel_search_sources() skips external API sources when index is
  available (0 GitHub calls for search)
- .github/workflows/skills-index.yml: twice-daily CI build + deploy
- .github/workflows/deploy-site.yml: also builds index during docs deploy

Graceful degradation: when the index is unavailable (first run, network
down, stale), all methods return empty/None and downstream sources
handle the request via direct API as before.
2026-04-12 16:39:04 -07:00
Teknium 7e0e5ea03b fix(skills): cache GitHub repo trees to avoid rate-limit exhaustion on install
Skills.sh installs hit the GitHub API 45 times per install because the
same repo tree was fetched 6 times redundantly. Combined with search
(23 API calls), this totals 68 — exceeding the unauthenticated rate
limit of 60 req/hr, causing 'Could not fetch' errors for users without
a GITHUB_TOKEN.

Changes:
- Add _get_repo_tree() cache to GitHubSource — repo info + recursive
  tree fetched once per repo per source instance, eliminating 10
  redundant API calls (6 tree + 4 candidate 404s)
- _download_directory_via_tree returns {} (not None) when cached tree
  shows path doesn't exist, skipping unnecessary Contents API fallback
- _check_rate_limit_response() detects exhausted quota and sets
  is_rate_limited flag
- do_install() shows actionable hint when rate limited: set
  GITHUB_TOKEN or install gh CLI

Before: 45 API calls per install (68 total with search)
After:  31 API calls per install (54 total with search — under 60/hr)

Reported by community user from Vietnam (no GitHub auth configured).
2026-04-12 16:39:04 -07:00
Teknium 4c6ebd077e chore: sync uv.lock with matrix extra deps (aiosqlite, asyncpg) (#8661)
These were already declared in pyproject.toml but missing from the lockfile.
2026-04-12 16:38:15 -07:00
alt-glitch 5e1197a42e fix(gateway): harden Docker/container gateway pathway
Centralize container detection in hermes_constants.is_container() with
process-lifetime caching, matching existing is_wsl()/is_termux() patterns.
Dedup _is_inside_container() in config.py to delegate to the new function.

Add _run_systemctl() wrapper that converts FileNotFoundError to RuntimeError
for defense-in-depth — all 10 bare subprocess.run(_systemctl_cmd(...)) call
sites now route through it.

Make supports_systemd_services() return False in containers and when
systemctl binary is absent (shutil.which check).

Add Docker-specific guidance in gateway_command() for install/uninstall/start
subcommands — exit 0 with helpful instructions instead of crashing.

Make 'hermes status' show 'Manager: docker (foreground)' and 'hermes dump'
show 'running (docker, pid N)' inside containers.

Fix setup_gateway() to use supports_systemd instead of _is_linux for all
systemd-related branches, and show Docker restart policy instructions in
containers.

Replace inline /.dockerenv check in voice_mode.py with is_container().

Fixes #7420

Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
2026-04-12 16:36:11 -07:00
sprmn24 18ab5c99d1 fix(backup): correct marker filenames in _validate_backup_zip
The backup validation checked for 'hermes_state.db' and 'memory_store.db'
as telltale markers of a valid Hermes backup zip. Neither name exists in a
real Hermes installation — the actual database file is 'state.db'
(hermes_state.py: DEFAULT_DB_PATH = get_hermes_home() / 'state.db').

A fresh Hermes installation produces:
  ~/.hermes/state.db        (actual name)
  ~/.hermes/config.yaml
  ~/.hermes/.env

Because the marker set never matched 'state.db', a backup zip containing
only 'state.db' plus 'config.yaml' would fail validation with:
  'zip does not appear to be a Hermes backup'
and the import would exit with sys.exit(1), silently rejecting a valid backup.

Fix: replace the wrong marker names with the correct filename.

Adds TestValidateBackupZip with three cases:
- state.db is accepted as a valid marker
- old wrong names (hermes_state.db, memory_store.db) alone are rejected
- config.yaml continues to pass (existing behaviour preserved)
2026-04-12 16:35:56 -07:00
Teknium d6785dc4d4 fix: empty response recovery for reasoning models (mimo, qwen, GLM) (#8609)
Three fixes for the (empty) response bug affecting open reasoning models:

1. Allow retries after prefill exhaustion — models like mimo-v2-pro always
   populate reasoning fields via OpenRouter, so the old 'not _has_structured'
   guard on the retry path blocked retries for EVERY reasoning model after
   the 2 prefill attempts.  Now: 2 prefills + 3 retries = 6 total attempts
   before (empty).

2. Reset prefill/retry counters on tool-call recovery — the counters
   accumulated across the entire conversation, never resetting during
   tool-calling turns.  A model cycling empty→prefill→tools→empty burned
   both prefill attempts and the third empty got zero recovery.  Now
   counters reset when prefill succeeds with tool calls.

3. Strip think blocks before _truly_empty check — inline <think> content
   made the string non-empty, skipping both retry paths.

Reported by users on Telegram with xiaomi/mimo-v2-pro and qwen3.5 models.
Reproduced: qwen3.5-9b emits tool calls as XML in reasoning field instead
of proper function calls, causing content=None + tool_calls=None + reasoning
with embedded <tool_call> XML.  Prefill recovery works but counter
accumulation caused permanent (empty) in long sessions.
2026-04-12 15:38:11 -07:00
Teknium a4593f8b21 feat: make gateway 'still working' notification interval configurable (#8572)
Add agent.gateway_notify_interval config option (default 600s).
Set to 0 to disable periodic 'still working' notifications.
Bridged to HERMES_AGENT_NOTIFY_INTERVAL env var (same pattern as
gateway_timeout and gateway_timeout_warning).

The inactivity warning (gateway_timeout_warning) was already
configurable; this makes the wall-clock ping configurable too.
2026-04-12 13:06:34 -07:00
Teknium 1179918746 fix: salvage follow-ups for Feishu QR onboarding (#7706)
- Remove duplicate _setup_feishu() definition (old 3-line version left
  behind by cherry-pick — Python picked the new one but dead code
  remained)
- Remove misleading 'Disable direct messages' DM option — the Feishu
  adapter has no DM policy mechanism, so 'disable' produced identical
  env vars to 'pairing'. Users who chose 'disable' would still see
  pairing prompts. Reduced to 3 options: pairing, allow-all, allowlist.
- Fix test_probe_returns_bot_info_on_success and
  test_probe_returns_none_on_failure: patch FEISHU_AVAILABLE=True so
  probe_bot() takes the SDK path when lark_oapi is not installed
2026-04-12 13:05:56 -07:00
Shuo d7785f4d5b feat(feishu): add scan-to-create onboarding for Feishu / Lark
Add a QR-based onboarding flow to `hermes gateway setup` for Feishu / Lark.
Users scan a QR code with their phone and the platform creates a fully
configured bot application automatically — matching the existing WeChat
QR login experience.

Setup flow:
- Choose between QR scan-to-create (new app) or manual credential input (existing app)
- Connection mode selection (WebSocket / Webhook)
- DM security policy (pairing / open / allowlist / disabled)
- Group chat policy (open with @mention / disabled)

Implementation:
- Onboard functions (init/begin/poll/QR/probe) in gateway/platforms/feishu.py
- _setup_feishu() in hermes_cli/gateway.py with manual fallback
- probe_bot uses lark_oapi SDK when available, raw HTTP fallback otherwise
- qr_register() catches expected errors (network/protocol), propagates bugs
- Poll handles HTTP 4xx JSON responses and feishu/lark domain auto-detection

Tests:
- 25 tests for onboard module (registration, QR, probe, contract, negative paths)
- 16 tests for setup flow (credentials, connection mode, DM policy, group policy,
  adapter integration verifying env vars produce valid FeishuAdapterSettings)

Change-Id: I720591ee84755f32dda95fbac4b26dc82cbcf823
2026-04-12 13:05:56 -07:00
Teknium a9ebb331bc fix: contextual error diagnostics for invalid API responses (#8565)
Previously, all invalid API responses (choices=None) were diagnosed
as 'fast response often indicates rate limiting' regardless of actual
response time or error code. A 738s Cloudflare 524 timeout was labeled
as 'fast response' and 'possible rate limit'.

Now extracts the error code from response.error and classifies:
- 524: upstream provider timed out (Cloudflare)
- 504: upstream gateway timeout
- 429: rate limited by upstream provider
- 500/502: upstream server error
- 503/529: upstream provider overloaded
- Other codes: shown with code number
- No code + <10s: likely rate limited (timing heuristic)
- No code + >60s: likely upstream timeout
- No code + 10-60s: neutral response time

All downstream messages (retry status, final error, interrupt message)
now use the classified hint instead of generic rate-limit language.

Reported by community member Lumen Radley (MiMo provider timeouts).
2026-04-12 13:00:07 -07:00
Teknium 400fe9b2a1 fix: add <thought> stripping to auxiliary_client + tests
auxiliary_client.py had its own regex mirroring _strip_think_blocks
but was missing the <thought> variant. Also adds test coverage for
<thought> paired and orphaned tags.
2026-04-12 12:44:49 -07:00
Chen Chia Yang 326d5febe5 fix: also strip <thought> tags during streaming in cli.py 2026-04-12 12:44:49 -07:00
Chen Chia Yang a372c14fc5 fix: strip <thought> tags from Gemma 4 responses in _strip_think_blocks
Gemma 4 (26B/31B) uses <thought>...</thought> to wrap its reasoning
output. This tag was not included in the existing list of reasoning tag
variants stripped by _strip_think_blocks(), causing raw thinking blocks
to leak into the visible response.

Added a new re.sub() line for <thought> and extended the cleanup regex
to include 'thought' alongside the existing variants.

Fixes #6148
2026-04-12 12:44:49 -07:00
Teknium f295b17d92 fix: make agent_thread daemon to prevent orphan CLI processes on tab close (#8557)
When a user closes a terminal tab, SIGHUP exits the main thread but
the non-daemon agent_thread kept the entire Python process alive —
stuck in the API call loop with no interrupt signal. Over many
conversations, these orphan processes accumulate and cause massive
swap usage (reported: 77GB on a 32GB M1 Pro).

Changes:
- Make agent_thread daemon=True so the process exits when the main
  thread finishes its cleanup. Under normal operation this changes
  nothing — the main thread already waits on agent_thread.is_alive().
- Interrupt the agent in the finally/exit path so the daemon thread
  stops making API calls promptly rather than being killed mid-flight.
2026-04-12 12:38:55 -07:00
Teknium 06290f6a2f fix: handle broken stdin in prompt_toolkit startup (#6393) (#8560)
On macOS with uv-managed Python, stdin (fd 0) can be invalid or
unregisterable with the asyncio selector, causing:

  KeyError: '0 is not registered'

during prompt_toolkit's app.run() → asyncio.run() → _add_reader(0).

Three-layer fix:
1. Pre-flight fstat(0) check before app.run() — detects broken stdin
   early and prints actionable guidance instead of a raw traceback.
2. Catch KeyError/OSError around app.run() as fallback for edge cases
   that slip past the fstat guard.
3. Extend asyncio exception handler to suppress selector registration
   KeyErrors in async callbacks.

Fixes #6393
2026-04-12 12:38:03 -07:00
Teknium 06a17c57ae fix: improve profile creation UX — seed SOUL.md + credential warning (#8553)
Fresh profiles (created without --clone) now:
- Auto-seed a default SOUL.md immediately, so users have a file to
  customize right away instead of discovering it only after first use
- Print a clear warning that the profile has no API keys and will
  inherit from the shell environment unless configured separately
- Show the SOUL.md path for personality customization

Previously, fresh profiles started with no SOUL.md (only seeded on
first use via ensure_hermes_home), no mention of credential isolation,
and no guidance about customizing personality. Users reported confusion
about profiles using the wrong model/plan tokens and SOUL.md not
being read — both traced to operational gaps in the creation UX.

Closes #8093 (investigated: code correctly loads SOUL.md from profile
HERMES_HOME; issue was operational, not a code bug).
2026-04-12 12:22:34 -07:00
284 changed files with 26452 additions and 3797 deletions
+1
View File
@@ -5,6 +5,7 @@
# Dependencies
node_modules
.venv
# CI/CD
.github
+9
View File
@@ -43,6 +43,15 @@
# KIMI_BASE_URL=https://api.kimi.com/coding/v1 # Default for sk-kimi- keys
# KIMI_BASE_URL=https://api.moonshot.ai/v1 # For legacy Moonshot keys
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys
# KIMI_CN_API_KEY= # Dedicated Moonshot China key
# =============================================================================
# LLM PROVIDER (Arcee AI)
# =============================================================================
# Arcee AI provides access to Trinity models (trinity-mini, trinity-large-*)
# Get an Arcee key at: https://chat.arcee.ai/
# ARCEEAI_API_KEY=
# ARCEE_BASE_URL= # Override default base URL
# =============================================================================
# LLM PROVIDER (MiniMax)
+2
View File
@@ -0,0 +1,2 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true
+24 -6
View File
@@ -11,6 +11,7 @@ body:
**Before submitting**, please:
- [ ] Search [existing issues](https://github.com/NousResearch/hermes-agent/issues) to avoid duplicates
- [ ] Update to the latest version (`hermes update`) and confirm the bug still exists
- [ ] Run `hermes debug share` and paste the links below (see Debug Report section)
- type: textarea
id: description
@@ -82,6 +83,25 @@ body:
- Slack
- WhatsApp
- type: textarea
id: debug-report
attributes:
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails, run `hermes debug share --local` and paste the output directly.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
render: shell
validations:
required: true
- type: input
id: os
attributes:
@@ -97,8 +117,6 @@ body:
label: Python Version
description: Output of `python --version`
placeholder: "3.11.9"
validations:
required: true
- type: input
id: hermes-version
@@ -106,14 +124,14 @@ body:
label: Hermes Version
description: Output of `hermes version`
placeholder: "2.1.0"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs / Traceback
description: Paste any error output, traceback, or log messages. This will be auto-formatted as code.
label: Additional Logs / Traceback (optional)
description: |
The debug report above covers most logs. Use this field for any extra error output,
tracebacks, or screenshots not captured by `hermes debug share`.
render: shell
- type: textarea
@@ -71,3 +71,15 @@ body:
label: Contribution
options:
- label: I'd like to implement this myself and submit a PR
- type: textarea
id: debug-report
attributes:
label: Debug Report (optional)
description: |
If this feature request is related to a problem you're experiencing, run `hermes debug share` and paste the links here.
In an interactive chat session, you can use `/debug` instead.
This helps us understand your environment and any related logs.
placeholder: |
Report https://paste.rs/abc123
render: shell
+16 -4
View File
@@ -9,7 +9,8 @@ body:
Sorry you're having trouble! Please fill out the details below so we can help.
**Quick checks first:**
- Run `hermes doctor` and include the output below
- Run `hermes debug share` and paste the links in the Debug Report section below
- If you're in a chat session, you can use `/debug` instead — it does the same thing
- Try `hermes update` to get the latest version
- Check the [README troubleshooting section](https://github.com/NousResearch/hermes-agent#troubleshooting)
- For general questions, consider the [Nous Research Discord](https://discord.gg/NousResearch) for faster help
@@ -74,10 +75,21 @@ body:
placeholder: "2.1.0"
- type: textarea
id: doctor-output
id: debug-report
attributes:
label: Output of `hermes doctor`
description: Run `hermes doctor` and paste the full output. This will be auto-formatted.
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails or install didn't get that far, run `hermes debug share --local` and paste the output directly.
If even that doesn't work, run `hermes doctor` and paste that output instead.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
render: shell
- type: textarea
+9 -1
View File
@@ -41,11 +41,19 @@ jobs:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml
run: pip install pyyaml httpx
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Build skills index (if not already present)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ! -f website/static/api/skills-index.json ]; then
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
fi
- name: Install dependencies
run: npm ci
working-directory: website
+101
View File
@@ -0,0 +1,101 @@
name: Build Skills Index
on:
schedule:
# Run twice daily: 6 AM and 6 PM UTC
- cron: '0 6,18 * * *'
workflow_dispatch: # Manual trigger
push:
branches: [main]
paths:
- 'scripts/build_skills_index.py'
- '.github/workflows/skills-index.yml'
permissions:
contents: read
jobs:
build-index:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install httpx pyyaml
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
uses: actions/upload-artifact@v4
with:
name: skills-index
path: website/static/api/skills-index.json
retention-days: 7
deploy-with-index:
needs: build-index
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
# Only deploy on schedule or manual trigger (not on every push to the script)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: skills-index
path: website/static/api/
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Install dependencies
run: npm ci
working-directory: website
- name: Build Docusaurus
run: npm run build
working-directory: website
- name: Stage deployment
run: |
mkdir -p _site/docs
cp -r landingpage/* _site/
cp -r website/build/* _site/docs/
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@v4
+1 -1
View File
@@ -183,7 +183,7 @@ jobs:
---
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.critical == 'true'
+4
View File
@@ -51,6 +51,9 @@ ignored/
.worktrees/
environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Release script temp files
.release_notes.md
mini-swe-agent/
@@ -58,3 +61,4 @@ mini-swe-agent/
# Nix
.direnv/
result
website/static/api/skills-index.json
+1 -1
View File
@@ -12,7 +12,7 @@ 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 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
-4
View File
@@ -1,4 +0,0 @@
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
+1
View File
@@ -167,6 +167,7 @@ python -m pytest tests/ -q
- 📚 [Skills Hub](https://agentskills.io)
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
- 💡 [Discussions](https://github.com/NousResearch/hermes-agent/discussions)
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
---
+329
View File
@@ -0,0 +1,329 @@
# Hermes Agent v0.9.0 (v2026.4.13)
**Release Date:** April 13, 2026
**Since v0.8.0:** 487 commits · 269 merged PRs · 167 resolved issues · 493 files changed · 63,281 insertions · 24 contributors
> The everywhere release — Hermes goes mobile with Termux/Android, adds iMessage and WeChat, ships Fast Mode for OpenAI and Anthropic, introduces background process monitoring, launches a local web dashboard for managing your agent, and delivers the deepest security hardening pass yet across 16 supported platforms.
---
## ✨ Highlights
- **Local Web Dashboard** — A new browser-based dashboard for managing your Hermes Agent locally. Configure settings, monitor sessions, browse skills, and manage your gateway — all from a clean web interface without touching config files or the terminal. The easiest way to get started with Hermes.
- **Fast Mode (`/fast`)** — Priority processing for OpenAI and Anthropic models. Toggle `/fast` to route through priority queues for significantly lower latency on supported models (GPT-5.4, Codex, Claude). Expands across all OpenAI Priority Processing models and Anthropic's fast tier. ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **iMessage via BlueBubbles** — Full iMessage integration through BlueBubbles, bringing Hermes to Apple's messaging ecosystem. Auto-webhook registration, setup wizard integration, and crash resilience. ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494))
- **WeChat (Weixin) & WeCom Callback Mode** — Native WeChat support via iLink Bot API and a new WeCom callback-mode adapter for self-built enterprise apps. Streaming cursor, media uploads, markdown link handling, and atomic state persistence. Hermes now covers the Chinese messaging ecosystem end-to-end. ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#7943](https://github.com/NousResearch/hermes-agent/pull/7943))
- **Termux / Android Support** — Run Hermes natively on Android via Termux. Adapted install paths, TUI optimizations for mobile screens, voice backend support, and the `/image` command work on-device. ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Background Process Monitoring (`watch_patterns`)** — Set patterns to watch for in background process output and get notified in real-time when they match. Monitor for errors, wait for specific events ("listening on port"), or watch build logs — all without polling. ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Native xAI & Xiaomi MiMo Providers** — First-class provider support for xAI (Grok) and Xiaomi MiMo, with direct API access, model catalogs, and setup wizard integration. Plus Qwen OAuth with portal request support. ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372), [#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Pluggable Context Engine** — Context management is now a pluggable slot via `hermes plugins`. Swap in custom context engines that control what the agent sees each turn — filtering, summarization, or domain-specific context injection. ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Unified Proxy Support** — SOCKS proxy, `DISCORD_PROXY`, and system proxy auto-detection across all gateway platforms. Hermes behind corporate firewalls just works. ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Comprehensive Security Hardening** — Path traversal protection in checkpoint manager, shell injection neutralization in sandbox writes, SSRF redirect guards in Slack image uploads, Twilio webhook signature validation (SMS RCE fix), API server auth enforcement, git argument injection prevention, and approval button authorization. ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933), [#7944](https://github.com/NousResearch/hermes-agent/pull/7944), [#7940](https://github.com/NousResearch/hermes-agent/pull/7940), [#7151](https://github.com/NousResearch/hermes-agent/pull/7151), [#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **`hermes backup` & `hermes import`** — Full backup and restore of your Hermes configuration, sessions, skills, and memory. Migrate between machines or create snapshots before major changes. ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **16 Supported Platforms** — With BlueBubbles (iMessage) and WeChat joining Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, SMS, DingTalk, Feishu, WeCom, Mattermost, Home Assistant, and Webhooks, Hermes now runs on 16 messaging platforms out of the box.
- **`/debug` & `hermes debug share`** — New debugging toolkit: `/debug` slash command across all platforms for quick diagnostics, plus `hermes debug share` to upload a full debug report to a pastebin for easy sharing when troubleshooting. ([#8681](https://github.com/NousResearch/hermes-agent/pull/8681))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Native xAI (Grok) provider** with direct API access and model catalog ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372))
- **Xiaomi MiMo as first-class provider** — setup wizard, model catalog, empty response recovery ([#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Qwen OAuth provider** with portal request support ([#6282](https://github.com/NousResearch/hermes-agent/pull/6282))
- **Fast Mode** — `/fast` toggle for OpenAI Priority Processing + Anthropic fast tier ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **Structured API error classification** for smart failover decisions ([#6514](https://github.com/NousResearch/hermes-agent/pull/6514))
- **Rate limit header capture** shown in `/usage` ([#6541](https://github.com/NousResearch/hermes-agent/pull/6541))
- **API server model name** derived from profile name ([#6857](https://github.com/NousResearch/hermes-agent/pull/6857))
- **Custom providers** now included in `/model` listings and resolution ([#7088](https://github.com/NousResearch/hermes-agent/pull/7088))
- **Fallback provider activation** on repeated empty responses with user-visible status ([#7505](https://github.com/NousResearch/hermes-agent/pull/7505))
- **OpenRouter variant tags** (`:free`, `:extended`, `:fast`) preserved during model switch ([#6383](https://github.com/NousResearch/hermes-agent/pull/6383))
- **Credential exhaustion TTL** reduced from 24 hours to 1 hour ([#6504](https://github.com/NousResearch/hermes-agent/pull/6504))
- **OAuth credential lifecycle** hardening — stale pool keys, auth.json sync, Codex CLI race fixes ([#6874](https://github.com/NousResearch/hermes-agent/pull/6874))
- Empty response recovery for reasoning models (MiMo, Qwen, GLM) ([#8609](https://github.com/NousResearch/hermes-agent/pull/8609))
- MiniMax context lengths, thinking guard, endpoint corrections ([#6082](https://github.com/NousResearch/hermes-agent/pull/6082), [#7126](https://github.com/NousResearch/hermes-agent/pull/7126))
- Z.AI endpoint auto-detect via probe and cache ([#5763](https://github.com/NousResearch/hermes-agent/pull/5763))
### Agent Loop & Conversation
- **Pluggable context engine slot** via `hermes plugins` ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Background process monitoring** — `watch_patterns` for real-time output alerts ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Improved context compression** — higher limits, tool tracking, degradation warnings, token-budget tail protection ([#6395](https://github.com/NousResearch/hermes-agent/pull/6395), [#6453](https://github.com/NousResearch/hermes-agent/pull/6453))
- **`/compress <focus>`** — guided compression with a focus topic ([#8017](https://github.com/NousResearch/hermes-agent/pull/8017))
- **Tiered context pressure warnings** with gateway dedup ([#6411](https://github.com/NousResearch/hermes-agent/pull/6411))
- **Staged inactivity warning** before timeout escalation ([#6387](https://github.com/NousResearch/hermes-agent/pull/6387))
- **Prevent agent from stopping mid-task** — compression floor, budget overhaul, activity tracking ([#7983](https://github.com/NousResearch/hermes-agent/pull/7983))
- **Propagate child activity to parent** during `delegate_task` ([#7295](https://github.com/NousResearch/hermes-agent/pull/7295))
- **Truncated streaming tool call detection** before execution ([#6847](https://github.com/NousResearch/hermes-agent/pull/6847))
- Empty response retry (3 attempts with nudge) ([#6488](https://github.com/NousResearch/hermes-agent/pull/6488))
- Adaptive streaming backoff + cursor strip to prevent message truncation ([#7683](https://github.com/NousResearch/hermes-agent/pull/7683))
- Compression uses live session model instead of stale persisted config ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258))
- Strip `<thought>` tags from Gemma 4 responses ([#8562](https://github.com/NousResearch/hermes-agent/pull/8562))
- Prevent `<think>` in prose from suppressing response output ([#6968](https://github.com/NousResearch/hermes-agent/pull/6968))
- Turn-exit diagnostic logging to agent loop ([#6549](https://github.com/NousResearch/hermes-agent/pull/6549))
- Scope tool interrupt signal per-thread to prevent cross-session leaks ([#7930](https://github.com/NousResearch/hermes-agent/pull/7930))
### Memory & Sessions
- **Hindsight memory plugin** — feature parity, setup wizard, config improvements — @nicoloboschi ([#6428](https://github.com/NousResearch/hermes-agent/pull/6428))
- **Honcho** — opt-in `initOnSessionStart` for tools mode — @Kathie-yu ([#6995](https://github.com/NousResearch/hermes-agent/pull/6995))
- Orphan children instead of cascade-deleting in prune/delete ([#6513](https://github.com/NousResearch/hermes-agent/pull/6513))
- Doctor command only checks the active memory provider ([#6285](https://github.com/NousResearch/hermes-agent/pull/6285))
---
## 📱 Messaging Platforms (Gateway)
### New Platforms
- **BlueBubbles (iMessage)** — full adapter with auto-webhook registration, setup wizard, and crash resilience ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494), [#7107](https://github.com/NousResearch/hermes-agent/pull/7107))
- **Weixin (WeChat)** — native support via iLink Bot API with streaming, media uploads, markdown links ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#8665](https://github.com/NousResearch/hermes-agent/pull/8665))
- **WeCom Callback Mode** — self-built enterprise app adapter with atomic state persistence ([#7943](https://github.com/NousResearch/hermes-agent/pull/7943), [#7928](https://github.com/NousResearch/hermes-agent/pull/7928))
### Discord
- **Allowed channels whitelist** config — @jarvis-phw ([#7044](https://github.com/NousResearch/hermes-agent/pull/7044))
- **Forum channel topic inheritance** in thread sessions — @hermes-agent-dhabibi ([#6377](https://github.com/NousResearch/hermes-agent/pull/6377))
- **DISCORD_REPLY_TO_MODE** setting ([#6333](https://github.com/NousResearch/hermes-agent/pull/6333))
- Accept `.log` attachments, raise document size limit — @kira-ariaki ([#6467](https://github.com/NousResearch/hermes-agent/pull/6467))
- Decouple readiness from slash sync ([#8016](https://github.com/NousResearch/hermes-agent/pull/8016))
### Slack
- **Consolidated Slack improvements** — 7 community PRs salvaged into one ([#6809](https://github.com/NousResearch/hermes-agent/pull/6809))
- Handle assistant thread lifecycle events ([#6433](https://github.com/NousResearch/hermes-agent/pull/6433))
### Matrix
- **Migrated from matrix-nio to mautrix-python** ([#7518](https://github.com/NousResearch/hermes-agent/pull/7518))
- SQLite crypto store replacing pickle (fixes E2EE decryption) — @alt-glitch ([#7981](https://github.com/NousResearch/hermes-agent/pull/7981))
- Cross-signing recovery key verification for E2EE migration ([#8282](https://github.com/NousResearch/hermes-agent/pull/8282))
- DM mention threads + group chat events for Feishu ([#7423](https://github.com/NousResearch/hermes-agent/pull/7423))
### Gateway Core
- **Unified proxy support** — SOCKS, DISCORD_PROXY, multi-platform with macOS auto-detection ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Inbound text batching** for Discord, Matrix, WeCom + adaptive delay ([#6979](https://github.com/NousResearch/hermes-agent/pull/6979))
- **Surface natural mid-turn assistant messages** in chat platforms ([#7978](https://github.com/NousResearch/hermes-agent/pull/7978))
- **WSL-aware gateway** with smart systemd detection ([#7510](https://github.com/NousResearch/hermes-agent/pull/7510))
- **All missing platforms added to setup wizard** ([#7949](https://github.com/NousResearch/hermes-agent/pull/7949))
- **Per-platform `tool_progress` overrides** ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- **Configurable 'still working' notification interval** ([#8572](https://github.com/NousResearch/hermes-agent/pull/8572))
- `/model` switch persists across messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- `/usage` shows rate limits, cost, and token details between turns ([#7038](https://github.com/NousResearch/hermes-agent/pull/7038))
- Drain in-flight work before restart ([#7503](https://github.com/NousResearch/hermes-agent/pull/7503))
- Don't evict cached agent on failed runs — prevents MCP restart loop ([#7539](https://github.com/NousResearch/hermes-agent/pull/7539))
- Replace `os.environ` session state with `contextvars` ([#7454](https://github.com/NousResearch/hermes-agent/pull/7454))
- Derive channel directory platforms from enum instead of hardcoded list ([#7450](https://github.com/NousResearch/hermes-agent/pull/7450))
- Validate image downloads before caching (cross-platform) ([#7125](https://github.com/NousResearch/hermes-agent/pull/7125))
- Cross-platform webhook delivery for all platforms ([#7095](https://github.com/NousResearch/hermes-agent/pull/7095))
- Cron Discord thread_id delivery support ([#7106](https://github.com/NousResearch/hermes-agent/pull/7106))
- Feishu QR-based bot onboarding ([#8570](https://github.com/NousResearch/hermes-agent/pull/8570))
- Gateway status scoped to active profile ([#7951](https://github.com/NousResearch/hermes-agent/pull/7951))
- Prevent background process notifications from triggering false pairing requests ([#6434](https://github.com/NousResearch/hermes-agent/pull/6434))
---
## 🖥️ CLI & User Experience
### Interactive CLI
- **Termux / Android support** — adapted install paths, TUI, voice, `/image` ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Native `/model` picker modal** for provider → model selection ([#8003](https://github.com/NousResearch/hermes-agent/pull/8003))
- **Live per-tool elapsed timer** restored in TUI spinner ([#7359](https://github.com/NousResearch/hermes-agent/pull/7359))
- **Stacked tool progress scrollback** in TUI ([#8201](https://github.com/NousResearch/hermes-agent/pull/8201))
- **Random tips on new session start** (CLI + gateway, 279 tips) ([#8225](https://github.com/NousResearch/hermes-agent/pull/8225), [#8237](https://github.com/NousResearch/hermes-agent/pull/8237))
- **`hermes dump`** — copy-pasteable setup summary for debugging ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- **`hermes backup` / `hermes import`** — full config backup and restore ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **WSL environment hint** in system prompt ([#8285](https://github.com/NousResearch/hermes-agent/pull/8285))
- **Profile creation UX** — seed SOUL.md + credential warning ([#8553](https://github.com/NousResearch/hermes-agent/pull/8553))
- Shell-aware sudo detection, empty password support ([#6517](https://github.com/NousResearch/hermes-agent/pull/6517))
- Flush stdin after curses/terminal menus to prevent escape sequence leakage ([#7167](https://github.com/NousResearch/hermes-agent/pull/7167))
- Handle broken stdin in prompt_toolkit startup ([#8560](https://github.com/NousResearch/hermes-agent/pull/8560))
### Setup & Configuration
- **Per-platform display verbosity** configuration ([#8006](https://github.com/NousResearch/hermes-agent/pull/8006))
- **Component-separated logging** with session context and filtering ([#7991](https://github.com/NousResearch/hermes-agent/pull/7991))
- **`network.force_ipv4`** config to fix IPv6 timeout issues ([#8196](https://github.com/NousResearch/hermes-agent/pull/8196))
- **Standardize message whitespace and JSON formatting** ([#7988](https://github.com/NousResearch/hermes-agent/pull/7988))
- **Rebrand OpenClaw → Hermes** during migration ([#8210](https://github.com/NousResearch/hermes-agent/pull/8210))
- Config.yaml takes priority over env vars for auxiliary settings ([#7889](https://github.com/NousResearch/hermes-agent/pull/7889))
- Harden setup provider flows + live OpenRouter catalog refresh ([#7078](https://github.com/NousResearch/hermes-agent/pull/7078))
- Normalize reasoning effort ordering across all surfaces ([#6804](https://github.com/NousResearch/hermes-agent/pull/6804))
- Remove dead `LLM_MODEL` env var + migration to clear stale entries ([#6543](https://github.com/NousResearch/hermes-agent/pull/6543))
- Remove `/prompt` slash command — prefix expansion footgun ([#6752](https://github.com/NousResearch/hermes-agent/pull/6752))
- `HERMES_HOME_MODE` env var to override permissions — @ygd58 ([#6993](https://github.com/NousResearch/hermes-agent/pull/6993))
- Fall back to default model when model config is empty ([#8303](https://github.com/NousResearch/hermes-agent/pull/8303))
- Warn when compression model context is too small ([#7894](https://github.com/NousResearch/hermes-agent/pull/7894))
---
## 🔧 Tool System
### Environments & Execution
- **Unified spawn-per-call execution layer** for environments ([#6343](https://github.com/NousResearch/hermes-agent/pull/6343))
- **Unified file sync** with mtime tracking, deletion, and transactional state ([#7087](https://github.com/NousResearch/hermes-agent/pull/7087))
- **Persistent sandbox envs** survive between turns ([#6412](https://github.com/NousResearch/hermes-agent/pull/6412))
- **Bulk file sync** via tar pipe for SSH/Modal backends — @alt-glitch ([#8014](https://github.com/NousResearch/hermes-agent/pull/8014))
- **Daytona** — bulk upload, config bridge, silent disk cap ([#7538](https://github.com/NousResearch/hermes-agent/pull/7538))
- Foreground timeout cap to prevent session deadlocks ([#7082](https://github.com/NousResearch/hermes-agent/pull/7082))
- Guard invalid command values ([#6417](https://github.com/NousResearch/hermes-agent/pull/6417))
### MCP
- **`hermes mcp add --env` and `--preset`** support ([#7970](https://github.com/NousResearch/hermes-agent/pull/7970))
- Combine `content` and `structuredContent` when both present ([#7118](https://github.com/NousResearch/hermes-agent/pull/7118))
- MCP tool name deconfliction fixes ([#7654](https://github.com/NousResearch/hermes-agent/pull/7654))
### Browser
- Browser hardening — dead code removal, caching, scroll perf, security, thread safety ([#7354](https://github.com/NousResearch/hermes-agent/pull/7354))
- `/browser connect` auto-launch uses dedicated Chrome profile dir ([#6821](https://github.com/NousResearch/hermes-agent/pull/6821))
- Reap orphaned browser sessions on startup ([#7931](https://github.com/NousResearch/hermes-agent/pull/7931))
### Voice & Vision
- **Voxtral TTS provider** (Mistral AI) ([#7653](https://github.com/NousResearch/hermes-agent/pull/7653))
- **TTS speed support** for Edge TTS, OpenAI TTS, MiniMax ([#8666](https://github.com/NousResearch/hermes-agent/pull/8666))
- **Vision auto-resize** for oversized images, raise limit to 20 MB, retry-on-failure ([#7883](https://github.com/NousResearch/hermes-agent/pull/7883), [#7902](https://github.com/NousResearch/hermes-agent/pull/7902))
- STT provider-model mismatch fix (whisper-1 vs faster-whisper) ([#7113](https://github.com/NousResearch/hermes-agent/pull/7113))
### Other Tools
- **`hermes dump`** command for setup summary ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- TODO store enforces ID uniqueness during replace operations ([#7986](https://github.com/NousResearch/hermes-agent/pull/7986))
- List all available toolsets in `delegate_task` schema description ([#8231](https://github.com/NousResearch/hermes-agent/pull/8231))
- API server: tool progress as custom SSE event to prevent model corruption ([#7500](https://github.com/NousResearch/hermes-agent/pull/7500))
- API server: share one Docker container across all conversations ([#7127](https://github.com/NousResearch/hermes-agent/pull/7127))
---
## 🧩 Skills Ecosystem
- **Centralized skills index + tree cache** — eliminates rate-limit failures on install ([#8575](https://github.com/NousResearch/hermes-agent/pull/8575))
- **More aggressive skill loading instructions** in system prompt (v3) ([#8209](https://github.com/NousResearch/hermes-agent/pull/8209), [#8286](https://github.com/NousResearch/hermes-agent/pull/8286))
- **Google Workspace skill** migrated to GWS CLI backend ([#6788](https://github.com/NousResearch/hermes-agent/pull/6788))
- **Creative divergence strategies** skill — @SHL0MS ([#6882](https://github.com/NousResearch/hermes-agent/pull/6882))
- **Creative ideation** — constraint-driven project generation — @SHL0MS ([#7555](https://github.com/NousResearch/hermes-agent/pull/7555))
- Parallelize skills browse/search to prevent hanging ([#7301](https://github.com/NousResearch/hermes-agent/pull/7301))
- Read name from SKILL.md frontmatter in skills_sync ([#7623](https://github.com/NousResearch/hermes-agent/pull/7623))
---
## 🔒 Security & Reliability
### Security Hardening
- **Twilio webhook signature validation** — SMS RCE fix ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933))
- **Shell injection neutralization** in `_write_to_sandbox` via path quoting ([#7940](https://github.com/NousResearch/hermes-agent/pull/7940))
- **Git argument injection** and path traversal prevention in checkpoint manager ([#7944](https://github.com/NousResearch/hermes-agent/pull/7944))
- **SSRF redirect bypass** in Slack image uploads + base.py cache helpers ([#7151](https://github.com/NousResearch/hermes-agent/pull/7151))
- **Path traversal, credential gate, DANGEROUS_PATTERNS gaps** ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **API bind guard** — enforce `API_SERVER_KEY` for non-loopback binding ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
- **Approval button authorization** — require auth for session continuation — @Cafexss ([#6930](https://github.com/NousResearch/hermes-agent/pull/6930))
- Path boundary enforcement in skill manager operations ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- DingTalk/API webhook URL origin validation, header injection rejection ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
### Reliability
- **Contextual error diagnostics** for invalid API responses ([#8565](https://github.com/NousResearch/hermes-agent/pull/8565))
- **Prevent 400 format errors** from triggering compression loop on Codex ([#6751](https://github.com/NousResearch/hermes-agent/pull/6751))
- **Don't halve context_length** on output-cap-too-large errors — @KUSH42 ([#6664](https://github.com/NousResearch/hermes-agent/pull/6664))
- **Recover primary client** on OpenAI transport errors ([#7108](https://github.com/NousResearch/hermes-agent/pull/7108))
- **Credential pool rotation** on billing-classified 400s ([#7112](https://github.com/NousResearch/hermes-agent/pull/7112))
- **Auto-increase stream read timeout** for local LLM providers ([#6967](https://github.com/NousResearch/hermes-agent/pull/6967))
- **Fall back to default certs** when CA bundle path doesn't exist ([#7352](https://github.com/NousResearch/hermes-agent/pull/7352))
- **Disambiguate usage-limit patterns** in error classifier — @sprmn24 ([#6836](https://github.com/NousResearch/hermes-agent/pull/6836))
- Harden cron script timeout and provider recovery ([#7079](https://github.com/NousResearch/hermes-agent/pull/7079))
- Gateway interrupt detection resilient to monitor task failures ([#8208](https://github.com/NousResearch/hermes-agent/pull/8208))
- Prevent unwanted session auto-reset after graceful gateway restarts ([#8299](https://github.com/NousResearch/hermes-agent/pull/8299))
- Prevent duplicate update prompt spam in gateway watcher ([#8343](https://github.com/NousResearch/hermes-agent/pull/8343))
- Deduplicate reasoning items in Responses API input ([#7946](https://github.com/NousResearch/hermes-agent/pull/7946))
### Infrastructure
- **Multi-arch Docker image** — amd64 + arm64 ([#6124](https://github.com/NousResearch/hermes-agent/pull/6124))
- **Docker runs as non-root user** with virtualenv — @benbarclay contributing ([#8226](https://github.com/NousResearch/hermes-agent/pull/8226))
- **Use `uv`** for Docker dependency resolution to fix resolution-too-deep ([#6965](https://github.com/NousResearch/hermes-agent/pull/6965))
- **Container-aware Nix CLI** — auto-route into managed container — @alt-glitch ([#7543](https://github.com/NousResearch/hermes-agent/pull/7543))
- **Nix shared-state permission model** for interactive CLI users — @alt-glitch ([#6796](https://github.com/NousResearch/hermes-agent/pull/6796))
- **Per-profile subprocess HOME isolation** ([#7357](https://github.com/NousResearch/hermes-agent/pull/7357))
- Profile paths fixed in Docker — profiles go to mounted volume ([#7170](https://github.com/NousResearch/hermes-agent/pull/7170))
- Docker container gateway pathway hardened ([#8614](https://github.com/NousResearch/hermes-agent/pull/8614))
- Enable unbuffered stdout for live Docker logs ([#6749](https://github.com/NousResearch/hermes-agent/pull/6749))
- Install procps in Docker image — @HiddenPuppy ([#7032](https://github.com/NousResearch/hermes-agent/pull/7032))
- Shallow git clone for faster installation — @sosyz ([#8396](https://github.com/NousResearch/hermes-agent/pull/8396))
- `hermes update` always reset on stash conflict ([#7010](https://github.com/NousResearch/hermes-agent/pull/7010))
- Write update exit code before gateway restart (cgroup kill race) ([#8288](https://github.com/NousResearch/hermes-agent/pull/8288))
- Nix: `setupSecrets` optional, tirith runtime dep — @devorun, @ethernet8023 ([#6261](https://github.com/NousResearch/hermes-agent/pull/6261), [#6721](https://github.com/NousResearch/hermes-agent/pull/6721))
- launchd stop uses `bootout` so `KeepAlive` doesn't respawn ([#7119](https://github.com/NousResearch/hermes-agent/pull/7119))
---
## 🐛 Notable Bug Fixes
- Fix: `/model` switch not persisting across gateway messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- Fix: session-scoped gateway model overrides ignored — @Hygaard ([#7662](https://github.com/NousResearch/hermes-agent/pull/7662))
- Fix: compaction model context length ignoring config — 3 related issues ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258), [#8107](https://github.com/NousResearch/hermes-agent/pull/8107))
- Fix: OpenCode.ai context window resolved to 128K instead of 1M ([#6472](https://github.com/NousResearch/hermes-agent/pull/6472))
- Fix: Codex fallback auth-store lookup — @cherifya ([#6462](https://github.com/NousResearch/hermes-agent/pull/6462))
- Fix: duplicate completion notifications when process killed ([#7124](https://github.com/NousResearch/hermes-agent/pull/7124))
- Fix: agent daemon thread prevents orphan CLI processes on tab close ([#8557](https://github.com/NousResearch/hermes-agent/pull/8557))
- Fix: stale image attachment on text paste and voice input ([#7077](https://github.com/NousResearch/hermes-agent/pull/7077))
- Fix: DM thread session seeding causing cross-thread contamination ([#7084](https://github.com/NousResearch/hermes-agent/pull/7084))
- Fix: OpenClaw migration shows dry-run preview before executing ([#6769](https://github.com/NousResearch/hermes-agent/pull/6769))
- Fix: auth errors misclassified as retryable — @kuishou68 ([#7027](https://github.com/NousResearch/hermes-agent/pull/7027))
- Fix: Copilot-Integration-Id header missing ([#7083](https://github.com/NousResearch/hermes-agent/pull/7083))
- Fix: ACP session capabilities — @luyao618 ([#6985](https://github.com/NousResearch/hermes-agent/pull/6985))
- Fix: ACP PromptResponse usage from top-level fields ([#7086](https://github.com/NousResearch/hermes-agent/pull/7086))
- Fix: several failing/flaky tests on main — @dsocolobsky ([#6777](https://github.com/NousResearch/hermes-agent/pull/6777))
- Fix: backup marker filenames — @sprmn24 ([#8600](https://github.com/NousResearch/hermes-agent/pull/8600))
- Fix: `NoneType` in fast_mode check — @0xbyt4 ([#7350](https://github.com/NousResearch/hermes-agent/pull/7350))
- Fix: missing imports in uninstall.py — @JiayuuWang ([#7034](https://github.com/NousResearch/hermes-agent/pull/7034))
---
## 📚 Documentation
- Platform adapter developer guide + WeCom Callback docs ([#7969](https://github.com/NousResearch/hermes-agent/pull/7969))
- Cron troubleshooting guide ([#7122](https://github.com/NousResearch/hermes-agent/pull/7122))
- Streaming timeout auto-detection for local LLMs ([#6990](https://github.com/NousResearch/hermes-agent/pull/6990))
- Tool-use enforcement documentation expanded ([#7984](https://github.com/NousResearch/hermes-agent/pull/7984))
- BlueBubbles pairing instructions ([#6548](https://github.com/NousResearch/hermes-agent/pull/6548))
- Telegram proxy support section ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- `hermes dump` and `hermes logs` CLI reference ([#6552](https://github.com/NousResearch/hermes-agent/pull/6552))
- `tool_progress_overrides` configuration reference ([#6364](https://github.com/NousResearch/hermes-agent/pull/6364))
- Compression model context length warning docs ([#7879](https://github.com/NousResearch/hermes-agent/pull/7879))
---
## 👥 Contributors
**269 merged PRs** from **24 contributors** across **487 commits**.
### Community Contributors
- **@alt-glitch** (6 PRs) — Nix container-aware CLI, shared-state permissions, Matrix SQLite crypto store, bulk SSH/Modal file sync, Matrix mautrix compat
- **@SHL0MS** (2 PRs) — Creative divergence strategies skill, creative ideation skill
- **@sprmn24** (2 PRs) — Error classifier disambiguation, backup marker fix
- **@nicoloboschi** — Hindsight memory plugin feature parity
- **@Hygaard** — Session-scoped gateway model override fix
- **@jarvis-phw** — Discord allowed_channels whitelist
- **@Kathie-yu** — Honcho initOnSessionStart for tools mode
- **@hermes-agent-dhabibi** — Discord forum channel topic inheritance
- **@kira-ariaki** — Discord .log attachments and size limit
- **@cherifya** — Codex fallback auth-store lookup
- **@Cafexss** — Security: auth for session continuation
- **@KUSH42** — Compaction context_length fix
- **@kuishou68** — Auth error retryable classification fix
- **@luyao618** — ACP session capabilities
- **@ygd58** — HERMES_HOME_MODE env var override
- **@0xbyt4** — Fast mode NoneType fix
- **@JiayuuWang** — CLI uninstall import fix
- **@HiddenPuppy** — Docker procps installation
- **@dsocolobsky** — Test suite fixes
- **@bobashopcashier** (1 PR) — Graceful gateway drain before restart (salvaged into #7503 from #7290)
- **@benbarclay** — Docker image tag simplification
- **@sosyz** — Shallow git clone for faster install
- **@devorun** — Nix setupSecrets optional
- **@ethernet8023** — Nix tirith runtime dep
---
**Full Changelog**: [v2026.4.8...v2026.4.13](https://github.com/NousResearch/hermes-agent/compare/v2026.4.8...v2026.4.13)
+71 -70
View File
@@ -27,10 +27,6 @@ Per-task overrides are configured in config.yaml under the ``auxiliary:`` sectio
(e.g. ``auxiliary.vision.provider``, ``auxiliary.compression.model``).
Default "auto" follows the chains above.
Legacy env var overrides (AUXILIARY_{TASK}_PROVIDER, AUXILIARY_{TASK}_MODEL,
AUXILIARY_{TASK}_BASE_URL, etc.) are still read as a backward-compat fallback
but config.yaml takes priority. New configuration should always use config.yaml.
Payment / credit exhaustion fallback:
When a resolved provider returns HTTP 402 or a credit-related error,
call_llm() automatically retries with the next available provider in the
@@ -68,6 +64,8 @@ _PROVIDER_ALIASES = {
"zhipu": "zai",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
"moonshot-cn": "kimi-coding-cn",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
@@ -75,13 +73,13 @@ _PROVIDER_ALIASES = {
}
def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False) -> str:
def _normalize_aux_provider(provider: Optional[str]) -> str:
normalized = (provider or "auto").strip().lower()
if normalized.startswith("custom:"):
suffix = normalized.split(":", 1)[1].strip()
if not suffix:
return "custom"
normalized = suffix if not for_vision else "custom"
normalized = suffix
if normalized == "codex":
return "openai-codex"
if normalized == "main":
@@ -98,6 +96,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
"kimi-coding-cn": "kimi-k2-turbo-preview",
"minimax": "MiniMax-M2.7",
"minimax-cn": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
@@ -753,30 +752,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
# ── Provider resolution helpers ─────────────────────────────────────────────
def _get_auxiliary_provider(task: str = "") -> str:
"""Read the provider override for a specific auxiliary task.
Checks AUXILIARY_{TASK}_PROVIDER first (e.g. AUXILIARY_VISION_PROVIDER),
then CONTEXT_{TASK}_PROVIDER (for the compression section's summary_provider),
then falls back to "auto". Returns one of: "auto", "openrouter", "nous", "main".
"""
if task:
for prefix in ("AUXILIARY_", "CONTEXT_"):
val = os.getenv(f"{prefix}{task.upper()}_PROVIDER", "").strip().lower()
if val and val != "auto":
return val
return "auto"
def _get_auxiliary_env_override(task: str, suffix: str) -> Optional[str]:
"""Read an auxiliary env override from AUXILIARY_* or CONTEXT_* prefixes."""
if not task:
return None
for prefix in ("AUXILIARY_", "CONTEXT_"):
val = os.getenv(f"{prefix}{task.upper()}_{suffix}", "").strip()
if val:
return val
return None
def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]:
@@ -1248,6 +1223,12 @@ def _to_async_client(sync_client, model: str):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
try:
from agent.copilot_acp_client import CopilotACPClient
if isinstance(sync_client, CopilotACPClient):
return sync_client, model
except ImportError:
pass
async_kwargs = {
"api_key": sync_client.api_key,
@@ -1466,10 +1447,14 @@ def resolve_provider_client(
custom_entry = _get_named_custom_provider(provider)
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip() or "no-key-required"
custom_key = custom_entry.get("api_key", "").strip()
custom_key_env = custom_entry.get("key_env", "").strip()
if not custom_key and custom_key_env:
custom_key = os.getenv(custom_key_env, "").strip()
custom_key = custom_key or "no-key-required"
if custom_base:
final_model = _normalize_resolved_model(
model or _read_main_model() or "gpt-4o-mini",
model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini",
provider,
)
client = OpenAI(api_key=custom_key, base_url=custom_base)
@@ -1488,7 +1473,11 @@ def resolve_provider_client(
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_api_key_provider_credentials,
resolve_external_process_provider_credentials,
)
except ImportError:
logger.debug("hermes_cli.auth not available for provider %s", provider)
return None, None
@@ -1562,6 +1551,41 @@ def resolve_provider_client(
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
if pconfig.auth_type == "external_process":
creds = resolve_external_process_provider_credentials(provider)
final_model = _normalize_resolved_model(model or _read_main_model(), provider)
if provider == "copilot-acp":
api_key = str(creds.get("api_key", "")).strip()
base_url = str(creds.get("base_url", "")).strip()
command = str(creds.get("command", "")).strip() or None
args = list(creds.get("args") or [])
if not final_model:
logger.warning(
"resolve_provider_client: copilot-acp requested but no model "
"was provided or configured"
)
return None, None
if not api_key or not base_url:
logger.warning(
"resolve_provider_client: copilot-acp requested but external "
"process credentials are incomplete"
)
return None, None
from agent.copilot_acp_client import CopilotACPClient
client = CopilotACPClient(
api_key=api_key,
base_url=base_url,
command=command,
args=args,
)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
logger.warning("resolve_provider_client: external-process provider %s not "
"directly supported", provider)
return None, None
elif pconfig.auth_type in ("oauth_device_code", "oauth_external"):
# OAuth providers — route through their specific try functions
if provider == "nous":
@@ -1591,8 +1615,8 @@ def get_text_auxiliary_client(
task: Optional task name ("compression", "web_extract") to check
for a task-specific provider override.
Callers may override the returned model with a per-task env var
(e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).
Callers may override the returned model via config.yaml
(e.g. auxiliary.compression.model, auxiliary.web_extract.model).
"""
provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
return resolve_provider_client(
@@ -1631,7 +1655,7 @@ _VISION_AUTO_PROVIDER_ORDER = (
def _normalize_vision_provider(provider: Optional[str]) -> str:
return _normalize_aux_provider(provider, for_vision=True)
return _normalize_aux_provider(provider)
def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:
@@ -1714,6 +1738,7 @@ def resolve_vision_provider_client(
async_mode=async_mode,
explicit_base_url=resolved_base_url,
explicit_api_key=resolved_api_key,
api_mode=resolved_api_mode,
)
if client is None:
return "custom", None, None
@@ -1738,7 +1763,8 @@ def resolve_vision_provider_client(
# Use provider-specific vision model if available, otherwise main model.
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model)
main_provider, vision_model,
api_mode=resolved_api_mode)
if rpc_client is not None:
logger.info(
"Vision auto-detect: using active provider %s (%s)",
@@ -1762,7 +1788,8 @@ def resolve_vision_provider_client(
sync_client, default_model = _resolve_strict_vision_backend(requested)
return _finalize(requested, sync_client, default_model)
client, final_model = _get_cached_client(requested, resolved_model, async_mode)
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
api_mode=resolved_api_mode)
if client is None:
return requested, None, None
return requested, client, final_model
@@ -2011,9 +2038,8 @@ def _resolve_task_provider_model(
Priority:
1. Explicit provider/model/base_url/api_key args (always win)
2. Config file (auxiliary.{task}.* or compression.*)
3. Env var overrides (backward-compat: AUXILIARY_{TASK}_*, CONTEXT_{TASK}_*)
4. "auto" (full auto-detection chain)
2. Config file (auxiliary.{task}.provider/model/base_url)
3. "auto" (full auto-detection chain)
Returns (provider, model, base_url, api_key, api_mode) where model may
be None (use provider default). When base_url is set, provider is forced
@@ -2044,22 +2070,8 @@ def _resolve_task_provider_model(
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# Backwards compat: compression section has its own keys.
# The auxiliary.compression defaults to provider="auto", so treat
# both None and "auto" as "not explicitly configured".
if task == "compression" and (not cfg_provider or cfg_provider == "auto"):
comp = config.get("compression", {}) if isinstance(config, dict) else {}
if isinstance(comp, dict):
cfg_provider = comp.get("summary_provider", "").strip() or None
cfg_model = cfg_model or comp.get("summary_model", "").strip() or None
_sbu = comp.get("summary_base_url") or ""
cfg_base_url = cfg_base_url or _sbu.strip() or None
# Env vars are backward-compat fallback only — config.yaml is primary.
env_model = _get_auxiliary_env_override(task, "MODEL") if task else None
env_api_mode = _get_auxiliary_env_override(task, "API_MODE") if task else None
resolved_model = model or cfg_model or env_model
resolved_api_mode = cfg_api_mode or env_api_mode
resolved_model = model or cfg_model
resolved_api_mode = cfg_api_mode
if base_url:
return "custom", resolved_model, base_url, api_key, resolved_api_mode
@@ -2073,17 +2085,6 @@ def _resolve_task_provider_model(
if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None, resolved_api_mode
# Env vars are backward-compat fallback for users who haven't
# migrated to config.yaml yet.
env_base_url = _get_auxiliary_env_override(task, "BASE_URL")
env_api_key = _get_auxiliary_env_override(task, "API_KEY")
if env_base_url:
return "custom", resolved_model, env_base_url, env_api_key, resolved_api_mode
env_provider = _get_auxiliary_provider(task)
if env_provider != "auto":
return env_provider, resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None, resolved_api_mode
@@ -2454,9 +2455,9 @@ def extract_content_or_reasoning(response) -> str:
if content:
# Strip inline think/reasoning blocks (mirrors _strip_think_blocks)
cleaned = re.sub(
r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>"
r"<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>"
r".*?"
r"</(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>",
r"</(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>",
"", content, flags=re.DOTALL | re.IGNORECASE,
).strip()
if cleaned:
+1 -1
View File
@@ -26,7 +26,7 @@ Lifecycle:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
class ContextEngine(ABC):
+8 -1
View File
@@ -18,7 +18,6 @@ import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
KIMI_CODE_BASE_URL,
PROVIDER_REGISTRY,
_auth_store_lock,
_codex_access_token_is_expiring,
@@ -289,6 +288,14 @@ def _iter_custom_providers(config: Optional[dict] = None):
return
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
# Fall back to the v12+ providers dict via the compatibility layer
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(config)
except Exception:
return
if not custom_providers:
return
for entry in custom_providers:
if not isinstance(entry, dict):
-6
View File
@@ -77,12 +77,6 @@ def _diff_ansi() -> dict[str, str]:
return _diff_colors_cached
def reset_diff_colors() -> None:
"""Reset cached diff colors (call after /skin switch)."""
global _diff_colors_cached
_diff_colors_cached = None
# Module-level helpers — each call resolves from the active skin lazily.
def _diff_dim(): return _diff_ansi()["dim"]
def _diff_file(): return _diff_ansi()["file"]
+12 -1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import enum
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
@@ -157,6 +156,18 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"prompt exceeds max length",
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
"exceeds the max_model_len",
"max_model_len",
"prompt length", # "engine prompt length X exceeds"
"input is too long",
"maximum model length",
# Ollama patterns
"context length exceeded",
"truncating input",
# llama.cpp / llama-server patterns
"slot context", # "slot context: N tokens, prompt N tokens"
"n_ctx_slot",
# Chinese error messages (some providers return these)
"超过最大长度",
"上下文长度",
-1
View File
@@ -27,7 +27,6 @@ from agent.usage_pricing import (
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
get_pricing,
has_known_pricing,
)
-1
View File
@@ -28,7 +28,6 @@ Usage in run_agent.py:
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
+17 -9
View File
@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
"""
import logging
import os
import re
import time
from pathlib import Path
@@ -24,17 +23,19 @@ logger = logging.getLogger(__name__)
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
"gemini", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
"qwen-oauth",
"xiaomi",
"arcee",
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"arcee-ai", "arceeai",
"qwen-portal",
})
@@ -211,7 +212,9 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"api.moonshot.ai": "kimi-coding",
"api.moonshot.cn": "kimi-coding-cn",
"api.kimi.com": "kimi-coding",
"api.arcee.ai": "arcee",
"api.minimax": "minimax",
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
@@ -775,12 +778,12 @@ def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
resp = client.post(f"{server_url}/api/show", json={"name": model})
if resp.status_code == 200:
data = resp.json()
# Check model_info for context length
model_info = data.get("model_info", {})
for key, value in model_info.items():
if "context_length" in key and isinstance(value, (int, float)):
return int(value)
# Check parameters string for num_ctx
# Prefer explicit num_ctx from Modelfile parameters: this is
# the *runtime* context Ollama will actually allocate KV cache
# for. The GGUF model_info.context_length is the training max,
# which can be larger than num_ctx — using it here would let
# Hermes grow conversations past the runtime limit and Ollama
# would silently truncate. Matches query_ollama_num_ctx().
params = data.get("parameters", "")
if "num_ctx" in params:
for line in params.split("\n"):
@@ -791,6 +794,11 @@ def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
return int(parts[-1])
except ValueError:
pass
# Fall back to GGUF model_info context_length (training max)
model_info = data.get("model_info", {})
for key, value in model_info.items():
if "context_length" in key and isinstance(value, (int, float)):
return int(value)
# LM Studio native API: /api/v1/models returns max_context_length.
# This is more reliable than the OpenAI-compat /v1/models which
+1 -96
View File
@@ -18,10 +18,8 @@ Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
"""
import difflib
import json
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
@@ -148,6 +146,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openai-codex": "openai",
"zai": "zai",
"kimi-coding": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
"minimax": "minimax",
"minimax-cn": "minimax-cn",
"deepseek": "deepseek",
@@ -176,13 +175,6 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
def _get_reverse_mapping() -> Dict[str, str]:
"""Return models.dev ID → Hermes provider ID mapping."""
global _MODELS_DEV_TO_PROVIDER
if _MODELS_DEV_TO_PROVIDER is None:
_MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
return _MODELS_DEV_TO_PROVIDER
def _get_cache_path() -> Path:
"""Return path to disk cache file."""
@@ -463,93 +455,6 @@ def list_agentic_models(provider: str) -> List[str]:
return result
def search_models_dev(
query: str, provider: str = None, limit: int = 5
) -> List[Dict[str, Any]]:
"""Fuzzy search across models.dev catalog. Returns matching model entries.
Args:
query: Search string to match against model IDs.
provider: Optional Hermes provider ID to restrict search scope.
If None, searches across all providers in PROVIDER_TO_MODELS_DEV.
limit: Maximum number of results to return.
Returns:
List of dicts, each containing 'provider', 'model_id', and the full
model 'entry' from models.dev.
"""
data = fetch_models_dev()
if not data:
return []
# Build list of (provider_id, model_id, entry) candidates
candidates: List[tuple] = []
if provider is not None:
# Search only the specified provider
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
if not mdev_provider_id:
return []
provider_data = data.get(mdev_provider_id, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((provider, mid, mdata))
else:
# Search across all mapped providers
for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items():
provider_data = data.get(mdev_prov, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((hermes_prov, mid, mdata))
if not candidates:
return []
# Use difflib for fuzzy matching — case-insensitive comparison
model_ids_lower = [c[1].lower() for c in candidates]
query_lower = query.lower()
# First try exact substring matches (more intuitive than pure edit-distance)
substring_matches = []
for prov, mid, mdata in candidates:
if query_lower in mid.lower():
substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata})
# Then add difflib fuzzy matches for any remaining slots
fuzzy_ids = difflib.get_close_matches(
query_lower, model_ids_lower, n=limit * 2, cutoff=0.4
)
seen_ids: set = set()
results: List[Dict[str, Any]] = []
# Prioritize substring matches
for match in substring_matches:
key = (match["provider"], match["model_id"])
if key not in seen_ids:
seen_ids.add(key)
results.append(match)
if len(results) >= limit:
return results
# Add fuzzy matches
for fid in fuzzy_ids:
# Find original-case candidates matching this lowered ID
for prov, mid, mdata in candidates:
if mid.lower() == fid:
key = (prov, mid)
if key not in seen_ids:
seen_ids.add(key)
results.append({"provider": prov, "model_id": mid, "entry": mdata})
if len(results) >= limit:
return results
return results
# ---------------------------------------------------------------------------
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
+12
View File
@@ -364,6 +364,18 @@ PLATFORM_HINTS = {
"documents. You can also include image URLs in markdown format ![alt](url) and they "
"will be downloaded and sent as native media when possible."
),
"wecom": (
"You are on WeCom (企业微信 / Enterprise WeChat). Markdown formatting is supported. "
"You CAN send media files natively — to deliver a file to the user, include "
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
"WeCom attachment: images (.jpg, .png, .webp) are sent as photos (up to 10 MB), "
"other files (.pdf, .docx, .xlsx, .md, .txt, etc.) arrive as downloadable documents "
"(up to 20 MB), and videos (.mp4) play inline. Voice messages are supported but "
"must be in AMR format — other audio formats are automatically sent as file attachments. "
"You can also include image URLs in markdown format ![alt](url) and they will be "
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
"capability — use MEDIA: syntax whenever a file delivery is appropriate."
),
}
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Mapping, Optional
from typing import Any, Mapping, Optional
@dataclass
-19
View File
@@ -575,25 +575,6 @@ def has_known_pricing(
return entry is not None
def get_pricing(
model_name: str,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Dict[str, float]:
"""Backward-compatible thin wrapper for legacy callers.
Returns only non-cache input/output fields when a pricing entry exists.
Unknown routes return zeroes.
"""
entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
if not entry:
return {"input": 0.0, "output": 0.0}
return {
"input": float(entry.input_cost_per_million or _ZERO),
"output": float(entry.output_cost_per_million or _ZERO),
}
def format_duration_compact(seconds: float) -> str:
if seconds < 60:
+3 -9
View File
@@ -25,6 +25,7 @@ model:
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
# "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY)
# "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY)
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
#
@@ -309,15 +310,8 @@ compression:
# compression of older turns.
protect_last_n: 20
# Model to use for generating summaries (fast/cheap recommended)
# This model compresses the middle turns into a concise summary.
# IMPORTANT: it receives the full middle section of the conversation, so it
# MUST support a context length at least as large as your main model's.
summary_model: "google/gemini-3-flash-preview"
# Provider for the summary model (default: "auto")
# Options: "auto", "openrouter", "nous", "main"
# summary_provider: "auto"
# To pin a specific model/provider for compression summaries, use the
# auxiliary section below (auxiliary.compression.provider / model).
# =============================================================================
# Auxiliary Models (Advanced — Experimental)
+195 -60
View File
@@ -237,7 +237,6 @@ def load_cli_config() -> Dict[str, Any]:
"compression": {
"enabled": True, # Auto-compress when approaching context limit
"threshold": 0.50, # Compress at 50% of model's context limit
"summary_model": "", # Model for summaries (empty = use main model)
},
"smart_model_routing": {
"enabled": False,
@@ -2420,8 +2419,8 @@ class HermesCLI:
# suppress them during streaming too — unless show_reasoning is
# enabled, in which case we route the inner content to the
# reasoning display box instead of discarding it.
_OPEN_TAGS = ("<REASONING_SCRATCHPAD>", "<think>", "<reasoning>", "<THINKING>", "<thinking>")
_CLOSE_TAGS = ("</REASONING_SCRATCHPAD>", "</think>", "</reasoning>", "</THINKING>", "</thinking>")
_OPEN_TAGS = ("<REASONING_SCRATCHPAD>", "<think>", "<reasoning>", "<THINKING>", "<thinking>", "<thought>")
_CLOSE_TAGS = ("</REASONING_SCRATCHPAD>", "</think>", "</reasoning>", "</THINKING>", "</thinking>", "</thought>")
# Append to a pre-filter buffer first
self._stream_prefilt = getattr(self, "_stream_prefilt", "") + text
@@ -2999,8 +2998,10 @@ class HermesCLI:
)
# Warn if the configured model is a Nous Hermes LLM (not agentic)
from hermes_cli.model_switch import is_nous_hermes_non_agentic
model_name = getattr(self, "model", "") or ""
if "hermes" in model_name.lower():
if is_nous_hermes_non_agentic(model_name):
self.console.print()
self.console.print(
"[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not "
@@ -3114,6 +3115,8 @@ class HermesCLI:
# Collect displayable entries (skip system, tool-result messages)
entries = [] # list of (role, display_text)
_last_asst_idx = None # index of last assistant entry
_last_asst_full = None # un-truncated display text for last assistant
for msg in self.conversation_history:
role = msg.get("role", "")
content = msg.get("content")
@@ -3143,7 +3146,9 @@ class HermesCLI:
text = "" if content is None else str(content)
text = _strip_reasoning(text)
parts = []
full_parts = [] # un-truncated version
if text:
full_parts.append(text)
lines = text.splitlines()
if len(lines) > MAX_ASST_LINES:
text = "\n".join(lines[:MAX_ASST_LINES]) + " ..."
@@ -3163,11 +3168,15 @@ class HermesCLI:
if len(names) > 4:
names_str += ", ..."
noun = "call" if tc_count == 1 else "calls"
parts.append(f"[{tc_count} tool {noun}: {names_str}]")
tc_summary = f"[{tc_count} tool {noun}: {names_str}]"
parts.append(tc_summary)
full_parts.append(tc_summary)
if not parts:
# Skip pure-reasoning messages that have no visible output
continue
entries.append(("assistant", " ".join(parts)))
_last_asst_idx = len(entries) - 1
_last_asst_full = " ".join(full_parts)
if not entries:
return
@@ -3178,6 +3187,13 @@ class HermesCLI:
skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2
entries = entries[skipped:]
# Replace last assistant entry with full (un-truncated) text
# so the user can see where they left off without wasting tokens.
if _last_asst_idx is not None and _last_asst_full:
adj_idx = _last_asst_idx - skipped
if 0 <= adj_idx < len(entries):
entries[adj_idx] = ("assistant_last", _last_asst_full)
# Build the display using Rich
from rich.panel import Panel
from rich.text import Text
@@ -3210,6 +3226,13 @@ class HermesCLI:
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
elif role == "assistant_last":
# Last assistant response shown in full, non-dim
lines.append(" ◆ Hermes: ", style=f"bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="")
else:
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
msg_lines = text.splitlines()
@@ -3354,6 +3377,93 @@ class HermesCLI:
# Treat as a git hash
return ref
def _handle_snapshot_command(self, command: str):
"""Handle /snapshot — lightweight state snapshots for Hermes config/state.
Syntax:
/snapshot list recent snapshots
/snapshot create [label] create a snapshot
/snapshot restore <id> restore state from snapshot
/snapshot prune [N] prune to N snapshots (default 20)
"""
from hermes_cli.backup import (
create_quick_snapshot, list_quick_snapshots,
restore_quick_snapshot, prune_quick_snapshots,
)
from hermes_constants import display_hermes_home
parts = command.split()
subcmd = parts[1].lower() if len(parts) > 1 else "list"
if subcmd in ("list", "ls"):
snaps = list_quick_snapshots()
if not snaps:
print(" No state snapshots yet.")
print(" Create one: /snapshot create [label]")
return
print(f" State snapshots ({display_hermes_home()}/state-snapshots/):\n")
print(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}")
print(f" {''*3} {''*35} {''*5} {''*10} {''*20}")
for i, s in enumerate(snaps, 1):
size = s.get("total_size", 0)
if size < 1024:
size_str = f"{size} B"
elif size < 1024 * 1024:
size_str = f"{size / 1024:.0f} KB"
else:
size_str = f"{size / 1024 / 1024:.1f} MB"
label = s.get("label") or ""
print(f" {i:3} {s['id']:<35} {s.get('file_count', 0):>5} {size_str:>10} {label}")
elif subcmd == "create":
label = " ".join(parts[2:]) if len(parts) > 2 else None
snap_id = create_quick_snapshot(label=label)
if snap_id:
print(f" Snapshot created: {snap_id}")
else:
print(" No state files found to snapshot.")
elif subcmd in ("restore", "rewind"):
if len(parts) < 3:
print(" Usage: /snapshot restore <snapshot-id>")
# Show hint with most recent snapshot
snaps = list_quick_snapshots(limit=1)
if snaps:
print(f" Most recent: {snaps[0]['id']}")
return
snap_id = parts[2]
# Allow restore by number (1-indexed)
try:
idx = int(snap_id)
snaps = list_quick_snapshots()
if 1 <= idx <= len(snaps):
snap_id = snaps[idx - 1]["id"]
else:
print(f" Invalid snapshot number. Use 1-{len(snaps)}.")
return
except ValueError:
pass
if restore_quick_snapshot(snap_id):
print(f" Restored state from: {snap_id}")
print(" Restart recommended for state.db changes to take effect.")
else:
print(f" Snapshot not found: {snap_id}")
elif subcmd == "prune":
keep = 20
if len(parts) > 2:
try:
keep = int(parts[2])
except ValueError:
print(" Usage: /snapshot prune [keep-count]")
return
deleted = prune_quick_snapshots(keep=keep)
print(f" Pruned {deleted} old snapshot(s) (keeping {keep}).")
else:
print(f" Unknown subcommand: {subcmd}")
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes.
@@ -4364,53 +4474,6 @@ class HermesCLI:
_ask()
return result[0]
def _interactive_provider_selection(
self, providers: list, current_model: str, current_provider: str
) -> str | None:
"""Show provider picker, return slug or None on cancel."""
choices = []
for p in providers:
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
if p.get("is_current"):
label += " ← current"
choices.append(label)
default_idx = next(
(i for i, p in enumerate(providers) if p.get("is_current")), 0
)
idx = self._run_curses_picker(
f"Select a provider (current: {current_model} on {current_provider}):",
choices,
default_index=default_idx,
)
if idx is None:
return None
return providers[idx]["slug"]
def _interactive_model_selection(
self, model_list: list, provider_data: dict
) -> str | None:
"""Show model picker for a given provider, return model_id or None on cancel."""
pname = provider_data.get("name", provider_data.get("slug", ""))
total = provider_data.get("total_models", len(model_list))
if not model_list:
_cprint(f"\n No models listed for {pname}.")
return self._prompt_text_input(" Enter model name manually (or Enter to cancel): ")
choices = list(model_list) + ["Enter custom model name"]
idx = self._run_curses_picker(
f"Select model from {pname} ({len(model_list)} of {total}):",
choices,
)
if idx is None:
return None
if idx < len(model_list):
return model_list[idx]
return self._prompt_text_input(" Enter model name: ")
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
@@ -4600,10 +4663,10 @@ class HermesCLI:
user_provs = None
custom_provs = None
try:
from hermes_cli.config import load_config
from hermes_cli.config import get_compatible_custom_providers, load_config
cfg = load_config()
user_provs = cfg.get("providers")
custom_provs = cfg.get("custom_providers")
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
pass
@@ -5391,10 +5454,16 @@ class HermesCLI:
self._show_usage()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "debug":
self._handle_debug_command()
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
self._handle_image_command(cmd_original)
elif canonical == "reload":
from hermes_cli.config import reload_env
count = reload_env()
print(f" Reloaded .env ({count} var(s) updated)")
elif canonical == "reload-mcp":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
@@ -5423,6 +5492,8 @@ class HermesCLI:
print(f"Plugin system error: {e}")
elif canonical == "rollback":
self._handle_rollback_command(cmd_original)
elif canonical == "snapshot":
self._handle_snapshot_command(cmd_original)
elif canonical == "stop":
self._handle_stop_command()
elif canonical == "background":
@@ -6305,6 +6376,14 @@ class HermesCLI:
except Exception as e:
print(f" ❌ Compression failed: {e}")
def _handle_debug_command(self):
"""Handle /debug — upload debug report + logs and print paste URLs."""
from hermes_cli.debug import run_debug_share
from types import SimpleNamespace
args = SimpleNamespace(lines=200, expire=7, local=False)
run_debug_share(args)
def _show_usage(self):
"""Show rate limits (if available) and session token usage."""
if not self.agent:
@@ -7617,8 +7696,10 @@ class HermesCLI:
"error": _summary,
}
# Start agent in background thread
agent_thread = threading.Thread(target=run_agent)
# Start agent in background thread (daemon so it cannot keep the
# process alive when the user closes the terminal tab — SIGHUP
# exits the main thread and daemon threads are reaped automatically).
agent_thread = threading.Thread(target=run_agent, daemon=True)
agent_thread.start()
# Monitor the dedicated interrupt queue while the agent runs.
@@ -7804,6 +7885,17 @@ class HermesCLI:
sys.stdout.write("\a")
sys.stdout.flush()
# Notify when iteration budget was hit
if result and not result.get("completed") and not result.get("interrupted"):
_api_calls = result.get("api_calls", 0)
if _api_calls >= getattr(self.agent, "max_iterations", 90):
_max_iter = getattr(self.agent, "max_iterations", 90)
_cprint(
f"\n{_DIM}⚠ Iteration budget reached "
f"({_api_calls}/{_max_iter}) — "
f"response may be incomplete{_RST}"
)
# Speak response aloud if voice TTS is enabled
# Skip batch TTS when streaming TTS already handled it
if self._voice_tts and response and not use_streaming_tts:
@@ -8644,6 +8736,9 @@ class HermesCLI:
if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
event.app.invalidate()
if pasted_text:
# Sanitize surrogate characters (e.g. from Word/Google Docs paste) before writing
from run_agent import _sanitize_surrogates
pasted_text = _sanitize_surrogates(pasted_text)
line_count = pasted_text.count('\n')
buf = event.current_buffer
if line_count >= 5 and not buf.text.strip().startswith('/'):
@@ -9569,17 +9664,37 @@ class HermesCLI:
pass # Signal handlers may fail in restricted environments
# Install a custom asyncio exception handler that suppresses the
# "Event loop is closed" RuntimeError from httpx transport cleanup.
# This is defense-in-depth — the primary fix is neuter_async_httpx_del
# which disables __del__ entirely, but older clients or SDK upgrades
# could bypass it.
# "Event loop is closed" RuntimeError from httpx transport cleanup
# and the "0 is not registered" KeyError from broken stdin (#6393).
# The RuntimeError fix is defense-in-depth — the primary fix is
# neuter_async_httpx_del which disables __del__ entirely. The
# KeyError fix handles macOS + uv-managed Python environments where
# fd 0 is not reliably available to the asyncio selector.
def _suppress_closed_loop_errors(loop, context):
exc = context.get("exception")
if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc):
return # silently suppress
if isinstance(exc, KeyError) and "is not registered" in str(exc):
return # suppress selector registration failures (#6393)
# Fall back to default handler for everything else
loop.default_exception_handler(context)
# Validate stdin before launching prompt_toolkit — on macOS with
# uv-managed Python, fd 0 can be invalid or unregisterable with the
# asyncio selector, causing "KeyError: '0 is not registered'" (#6393).
try:
import os as _os
_os.fstat(0)
except OSError:
print(
"Error: stdin (fd 0) is not available.\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
)
_run_cleanup()
self._print_exit_summary()
return
# Run the application with patch_stdout for proper output handling
try:
with patch_stdout():
@@ -9593,8 +9708,28 @@ class HermesCLI:
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
except (KeyError, OSError) as _stdin_err:
# Catch selector registration failures from broken stdin (#6393).
# This is the fallback for cases that slip past the fstat() guard.
if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err):
print(
f"\nError: stdin is not usable ({_stdin_err}).\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
)
else:
raise
finally:
self._should_exit = True
# Interrupt the agent immediately so its daemon thread stops making
# API calls and exits promptly (agent_thread is daemon, so the
# process will exit once the main thread finishes, but interrupting
# avoids wasted API calls and lets run_conversation clean up).
if self.agent and getattr(self, '_agent_running', False):
try:
self.agent.interrupt()
except Exception:
pass
# Flush memories before exit (only for substantial conversations)
if self.agent and self.conversation_history:
try:
-2
View File
@@ -18,9 +18,7 @@ suppress delivery.
"""
import logging
import os
import threading
from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
+36 -1
View File
@@ -665,6 +665,17 @@ def load_gateway_config() -> GatewayConfig:
_apply_env_overrides(config)
# --- Validate loaded values ---
_validate_gateway_config(config)
return config
def _validate_gateway_config(config: "GatewayConfig") -> None:
"""Validate and sanitize a loaded GatewayConfig in place.
Called by ``load_gateway_config()`` after all config sources are merged.
Extracted as a separate function for testability.
"""
policy = config.default_reset_policy
if not (0 <= policy.at_hour <= 23):
@@ -701,7 +712,31 @@ def load_gateway_config() -> GatewayConfig:
platform.value, env_name,
)
return config
# Reject known-weak placeholder tokens.
# Ported from openclaw/openclaw#64586: users who copy .env.example
# without changing placeholder values get a clear startup error instead
# of a confusing "auth failed" from the platform API.
try:
from hermes_cli.auth import has_usable_secret
except ImportError:
has_usable_secret = None # type: ignore[assignment]
if has_usable_secret is not None:
for platform, pconfig in config.platforms.items():
if not pconfig.enabled:
continue
env_name = _token_env_names.get(platform)
if not env_name:
continue
token = pconfig.token
if token and token.strip() and not has_usable_secret(token, min_length=4):
logger.error(
"%s is enabled but %s is set to a placeholder value ('%s'). "
"Set a real bot token before starting the gateway. "
"The adapter will NOT be started.",
platform.value, env_name, token.strip()[:6] + "...",
)
pconfig.enabled = False
def _apply_env_overrides(config: GatewayConfig) -> None:
+1 -1
View File
@@ -12,7 +12,7 @@ import logging
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from typing import Dict, List, Optional, Any, Union
from typing import Dict, List, Optional, Any
from hermes_cli.config import get_hermes_home
+1 -20
View File
@@ -82,7 +82,7 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Tier 3 — no edit support, progress messages are permanent
"signal": _TIER_LOW,
"whatsapp": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
"wecom": _TIER_LOW,
@@ -163,25 +163,6 @@ def resolve_display_setting(
return fallback
def get_platform_defaults(platform_key: str) -> dict[str, Any]:
"""Return the built-in default display settings for a platform.
Falls back to ``_GLOBAL_DEFAULTS`` for unknown platforms.
"""
return dict(_PLATFORM_DEFAULTS.get(platform_key, _GLOBAL_DEFAULTS))
def get_effective_display(user_config: dict, platform_key: str) -> dict[str, Any]:
"""Return the fully-resolved display settings for a platform.
Useful for status commands that want to show all effective settings.
"""
return {
key: resolve_display_setting(user_config, platform_key, key)
for key in OVERRIDEABLE_KEYS
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+79 -13
View File
@@ -54,6 +54,66 @@ DEFAULT_PORT = 8642
MAX_STORED_RESPONSES = 100
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
def _normalize_chat_content(
content: Any, *, _max_depth: int = 10, _depth: int = 0,
) -> str:
"""Normalize OpenAI chat message content into a plain text string.
Some clients (Open WebUI, LobeChat, etc.) send content as an array of
typed parts instead of a plain string::
[{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}]
This function flattens those into a single string so the agent pipeline
(which expects strings) doesn't choke.
Defensive limits prevent abuse: recursion depth, list size, and output
length are all bounded.
"""
if _depth > _max_depth:
return ""
if content is None:
return ""
if isinstance(content, str):
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
if isinstance(content, list):
parts: List[str] = []
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
for item in items:
if isinstance(item, str):
if item:
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
elif isinstance(item, dict):
item_type = str(item.get("type") or "").strip().lower()
if item_type in {"text", "input_text", "output_text"}:
text = item.get("text", "")
if text:
try:
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
except Exception:
pass
# Silently skip image_url / other non-text parts
elif isinstance(item, list):
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
if nested:
parts.append(nested)
# Check accumulated size
if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
break
result = "\n".join(parts)
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
# Fallback for unexpected types (int, float, bool, etc.)
try:
result = str(content)
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
except Exception:
return ""
def check_api_server_requirements() -> bool:
@@ -553,7 +613,7 @@ class APIServerAdapter(BasePlatformAdapter):
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
content = _normalize_chat_content(msg.get("content", ""))
if role == "system":
# Accumulate system messages
if system_prompt is None:
@@ -926,18 +986,7 @@ class APIServerAdapter(BasePlatformAdapter):
input_messages.append({"role": "user", "content": item})
elif isinstance(item, dict):
role = item.get("role", "user")
content = item.get("content", "")
# Handle content that may be a list of content parts
if isinstance(content, list):
text_parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "input_text":
text_parts.append(part.get("text", ""))
elif isinstance(part, dict) and part.get("type") == "output_text":
text_parts.append(part.get("text", ""))
elif isinstance(part, str):
text_parts.append(part)
content = "\n".join(text_parts)
content = _normalize_chat_content(item.get("content", ""))
input_messages.append({"role": role, "content": content})
else:
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
@@ -1770,6 +1819,23 @@ class APIServerAdapter(BasePlatformAdapter):
)
return False
# Refuse to start network-accessible with a placeholder key.
# Ported from openclaw/openclaw#64586.
if is_network_accessible(self._host) and self._api_key:
try:
from hermes_cli.auth import has_usable_secret
if not has_usable_secret(self._api_key, min_length=8):
logger.error(
"[%s] Refusing to start: API_SERVER_KEY is set to a "
"placeholder value. Generate a real secret "
"(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY "
"before exposing the API server on %s.",
self.name, self._host,
)
return False
except ImportError:
pass
# Port conflict detection — fail fast if port is already in use
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
+82 -9
View File
@@ -21,6 +21,59 @@ from urllib.parse import urlsplit
logger = logging.getLogger(__name__)
def utf16_len(s: str) -> int:
"""Count UTF-16 code units in *s*.
Telegram's message-length limit (4 096) is measured in UTF-16 code units,
**not** Unicode code-points. Characters outside the Basic Multilingual
Plane (emoji like 😀, CJK Extension B, musical symbols, ) are encoded as
surrogate pairs and therefore consume **two** UTF-16 code units each, even
though Python's ``len()`` counts them as one.
Ported from nearai/ironclaw#2304 which discovered the same discrepancy in
Rust's ``chars().count()``.
"""
return len(s.encode("utf-16-le")) // 2
def _prefix_within_utf16_limit(s: str, limit: int) -> str:
"""Return the longest prefix of *s* whose UTF-16 length ≤ *limit*.
Unlike a plain ``s[:limit]``, this respects surrogate-pair boundaries so
we never slice a multi-code-unit character in half.
"""
if utf16_len(s) <= limit:
return s
# Binary search for the longest safe prefix
lo, hi = 0, len(s)
while lo < hi:
mid = (lo + hi + 1) // 2
if utf16_len(s[:mid]) <= limit:
lo = mid
else:
hi = mid - 1
return s[:lo]
def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int:
"""Return the largest codepoint offset *n* such that ``len_fn(s[:n]) <= budget``.
Used by :meth:`BasePlatformAdapter.truncate_message` when *len_fn* measures
length in units different from Python codepoints (e.g. UTF-16 code units).
Falls back to binary search which is O(log n) calls to *len_fn*.
"""
if len_fn(s) <= budget:
return len(s)
lo, hi = 0, len(s)
while lo < hi:
mid = (lo + hi + 1) // 2
if len_fn(s[:mid]) <= budget:
lo = mid
else:
hi = mid - 1
return lo
def is_network_accessible(host: str) -> bool:
"""Return True if *host* would expose the server beyond loopback.
@@ -1886,7 +1939,11 @@ class BasePlatformAdapter(ABC):
return content
@staticmethod
def truncate_message(content: str, max_length: int = 4096) -> List[str]:
def truncate_message(
content: str,
max_length: int = 4096,
len_fn: Optional["Callable[[str], int]"] = None,
) -> List[str]:
"""
Split a long message into chunks, preserving code block boundaries.
@@ -1898,11 +1955,16 @@ class BasePlatformAdapter(ABC):
Args:
content: The full message content
max_length: Maximum length per chunk (platform-specific)
len_fn: Optional length function for measuring string length.
Defaults to ``len`` (Unicode code-points). Pass
``utf16_len`` for platforms that measure message
length in UTF-16 code units (e.g. Telegram).
Returns:
List of message chunks
"""
if len(content) <= max_length:
_len = len_fn or len
if _len(content) <= max_length:
return [content]
INDICATOR_RESERVE = 10 # room for " (XX/XX)"
@@ -1921,22 +1983,33 @@ class BasePlatformAdapter(ABC):
# How much body text we can fit after accounting for the prefix,
# a potential closing fence, and the chunk indicator.
headroom = max_length - INDICATOR_RESERVE - len(prefix) - len(FENCE_CLOSE)
headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE)
if headroom < 1:
headroom = max_length // 2
# Everything remaining fits in one final chunk
if len(prefix) + len(remaining) <= max_length - INDICATOR_RESERVE:
if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE:
chunks.append(prefix + remaining)
break
# Find a natural split point (prefer newlines, then spaces)
region = remaining[:headroom]
# Find a natural split point (prefer newlines, then spaces).
# When _len != len (e.g. utf16_len for Telegram), headroom is
# measured in the custom unit. We need codepoint-based slice
# positions that stay within the custom-unit budget.
#
# _safe_slice_pos() maps a custom-unit budget to the largest
# codepoint offset whose custom length ≤ budget.
if _len is not len:
# Map headroom (custom units) → codepoint slice length
_cp_limit = _custom_unit_to_cp(remaining, headroom, _len)
else:
_cp_limit = headroom
region = remaining[:_cp_limit]
split_at = region.rfind("\n")
if split_at < headroom // 2:
if split_at < _cp_limit // 2:
split_at = region.rfind(" ")
if split_at < 1:
split_at = headroom
split_at = _cp_limit
# Avoid splitting inside an inline code span (`...`).
# If the text before split_at has an odd number of unescaped
@@ -1956,7 +2029,7 @@ class BasePlatformAdapter(ABC):
safe_split = candidate.rfind(" ", 0, last_bt)
nl_split = candidate.rfind("\n", 0, last_bt)
safe_split = max(safe_split, nl_split)
if safe_split > headroom // 4:
if safe_split > _cp_limit // 4:
split_at = safe_split
chunk_body = remaining[:split_at]
-29
View File
@@ -604,35 +604,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
# Tapback reactions
# ------------------------------------------------------------------
async def send_reaction(
self,
chat_id: str,
message_guid: str,
reaction: str,
part_index: int = 0,
) -> SendResult:
"""Send a tapback reaction (requires Private API helper)."""
if not self._private_api_enabled or not self._helper_connected:
return SendResult(
success=False, error="Private API helper not connected"
)
guid = await self._resolve_chat_guid(chat_id)
if not guid:
return SendResult(success=False, error=f"Chat not found: {chat_id}")
try:
res = await self._api_post(
"/api/v1/message/react",
{
"chatGuid": guid,
"selectedMessageGuid": message_guid,
"reaction": reaction,
"partIndex": part_index,
},
)
return SendResult(success=True, raw_response=res)
except Exception as exc:
return SendResult(success=False, error=str(exc))
# ------------------------------------------------------------------
# Chat info
# ------------------------------------------------------------------
-1
View File
@@ -21,7 +21,6 @@ import asyncio
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
+10 -4
View File
@@ -10,7 +10,6 @@ Uses discord.py library for:
"""
import asyncio
import json
import logging
import os
import struct
@@ -19,7 +18,6 @@ import tempfile
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Callable, Dict, Optional, Any
logger = logging.getLogger(__name__)
@@ -442,6 +440,7 @@ class DiscordAdapter(BasePlatformAdapter):
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id
self._voice_sources: Dict[int, Dict[str, Any]] = {} # guild_id -> linked text channel source metadata
self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task
# Phase 2: voice listening
self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver
@@ -1045,6 +1044,7 @@ class DiscordAdapter(BasePlatformAdapter):
if task:
task.cancel()
self._voice_text_channels.pop(guild_id, None)
self._voice_sources.pop(guild_id, None)
# Maximum seconds to wait for voice playback before giving up
PLAYBACK_TIMEOUT = 120
@@ -2244,6 +2244,7 @@ class DiscordAdapter(BasePlatformAdapter):
thread_id = str(message.channel.id)
parent_channel_id = self._get_parent_channel_id(message.channel)
is_voice_linked_channel = False
if not isinstance(message.channel, discord.DMChannel):
channel_ids = {str(message.channel.id)}
if parent_channel_id:
@@ -2270,7 +2271,12 @@ class DiscordAdapter(BasePlatformAdapter):
channel_ids.add(parent_channel_id)
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
is_free_channel = bool(channel_ids & free_channels)
# Voice-linked text channels act as free-response while voice is active.
# Only the exact bound channel gets the exemption, not sibling threads.
voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
current_channel_id = str(message.channel.id)
is_voice_linked_channel = current_channel_id in voice_linked_ids
is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel
# Skip the mention check if the message is in a thread where
# the bot has previously participated (auto-created or replied in).
@@ -2294,7 +2300,7 @@ class DiscordAdapter(BasePlatformAdapter):
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
skip_thread = bool(channel_ids & no_thread_channels)
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
if auto_thread and not skip_thread:
if auto_thread and not skip_thread and not is_voice_linked_channel:
thread = await self._auto_create_thread(message)
if thread:
is_thread = True
+341 -14
View File
@@ -34,6 +34,9 @@ from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
# aiohttp/websockets are independent optional deps — import outside lark_oapi
# so they remain available for tests and webhook mode even if lark_oapi is missing.
@@ -169,6 +172,19 @@ _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup win
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
_FEISHU_ACK_EMOJI = "OK"
# QR onboarding constants
_ONBOARD_ACCOUNTS_URLS = {
"feishu": "https://accounts.feishu.cn",
"lark": "https://accounts.larksuite.com",
}
_ONBOARD_OPEN_URLS = {
"feishu": "https://open.feishu.cn",
"lark": "https://open.larksuite.com",
}
_REGISTRATION_PATH = "/oauth/v1/app/registration"
_ONBOARD_REQUEST_TIMEOUT_S = 10
# ---------------------------------------------------------------------------
# Fallback display strings
# ---------------------------------------------------------------------------
@@ -414,14 +430,6 @@ def _build_markdown_post_payload(content: str) -> str:
)
def parse_feishu_post_content(raw_content: str) -> FeishuPostParseResult:
try:
parsed = json.loads(raw_content) if raw_content else {}
except json.JSONDecodeError:
return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
return parse_feishu_post_payload(parsed)
def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
resolved = _resolve_post_payload(payload)
if not resolved:
@@ -2672,12 +2680,6 @@ class FeishuAdapter(BasePlatformAdapter):
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
return MessageType.TEXT
def _normalize_inbound_text(self, text: str) -> str:
"""Strip Feishu mention placeholders from inbound text."""
text = _MENTION_RE.sub(" ", text or "")
text = _MULTISPACE_RE.sub(" ", text)
return text.strip()
async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
if not cached_path or not media_type.startswith("text/"):
return ""
@@ -3621,3 +3623,328 @@ class FeishuAdapter(BasePlatformAdapter):
return _FEISHU_FILE_UPLOAD_TYPE, "file"
return _FEISHU_FILE_UPLOAD_TYPE, "file"
# =============================================================================
# QR scan-to-create onboarding
#
# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the
# platform creates a fully configured bot application automatically.
# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py.
# =============================================================================
def _accounts_base_url(domain: str) -> str:
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
def _onboard_open_base_url(domain: str) -> str:
return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"])
def _post_registration(base_url: str, body: Dict[str, str]) -> dict:
"""POST form-encoded data to the registration endpoint, return parsed JSON.
The registration endpoint returns JSON even on 4xx (e.g. poll returns
authorization_pending as a 400). We always parse the body regardless of
HTTP status.
"""
url = f"{base_url}{_REGISTRATION_PATH}"
data = urlencode(body).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as exc:
body_bytes = exc.read()
if body_bytes:
try:
return json.loads(body_bytes.decode("utf-8"))
except (ValueError, json.JSONDecodeError):
raise exc from None
raise
def _init_registration(domain: str = "feishu") -> None:
"""Verify the environment supports client_secret auth.
Raises RuntimeError if not supported.
"""
base_url = _accounts_base_url(domain)
res = _post_registration(base_url, {"action": "init"})
methods = res.get("supported_auth_methods") or []
if "client_secret" not in methods:
raise RuntimeError(
f"Feishu / Lark registration environment does not support client_secret auth. "
f"Supported: {methods}"
)
def _begin_registration(domain: str = "feishu") -> dict:
"""Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in."""
base_url = _accounts_base_url(domain)
res = _post_registration(base_url, {
"action": "begin",
"archetype": "PersonalAgent",
"auth_method": "client_secret",
"request_user_info": "open_id",
})
device_code = res.get("device_code")
if not device_code:
raise RuntimeError("Feishu / Lark registration did not return a device_code")
qr_url = res.get("verification_uri_complete", "")
if "?" in qr_url:
qr_url += "&from=hermes&tp=hermes"
else:
qr_url += "?from=hermes&tp=hermes"
return {
"device_code": device_code,
"qr_url": qr_url,
"user_code": res.get("user_code", ""),
"interval": res.get("interval") or 5,
"expire_in": res.get("expire_in") or 600,
}
def _poll_registration(
*,
device_code: str,
interval: int,
expire_in: int,
domain: str = "feishu",
) -> Optional[dict]:
"""Poll until the user scans the QR code, or timeout/denial.
Returns dict with app_id, app_secret, domain, open_id on success.
Returns None on failure.
"""
deadline = time.time() + expire_in
current_domain = domain
domain_switched = False
poll_count = 0
while time.time() < deadline:
base_url = _accounts_base_url(current_domain)
try:
res = _post_registration(base_url, {
"action": "poll",
"device_code": device_code,
"tp": "ob_app",
})
except (URLError, OSError, json.JSONDecodeError):
time.sleep(interval)
continue
poll_count += 1
if poll_count == 1:
print(" Fetching configuration results...", end="", flush=True)
elif poll_count % 6 == 0:
print(".", end="", flush=True)
# Domain auto-detection
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark" and not domain_switched:
current_domain = "lark"
domain_switched = True
# Fall through — server may return credentials in this same response.
# Success
if res.get("client_id") and res.get("client_secret"):
if poll_count > 0:
print() # newline after "Fetching configuration results..." dots
return {
"app_id": res["client_id"],
"app_secret": res["client_secret"],
"domain": current_domain,
"open_id": user_info.get("open_id"),
}
# Terminal errors
error = res.get("error", "")
if error in ("access_denied", "expired_token"):
if poll_count > 0:
print()
logger.warning("[Feishu onboard] Registration %s", error)
return None
# authorization_pending or unknown — keep polling
time.sleep(interval)
if poll_count > 0:
print()
logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in)
return None
try:
import qrcode as _qrcode_mod
except (ImportError, TypeError):
_qrcode_mod = None # type: ignore[assignment]
def _render_qr(url: str) -> bool:
"""Try to render a QR code in the terminal. Returns True if successful."""
if _qrcode_mod is None:
return False
try:
qr = _qrcode_mod.QRCode()
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
return True
except Exception:
return False
def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
"""Verify bot connectivity via /open-apis/bot/v3/info.
Uses lark_oapi SDK when available, falls back to raw HTTP otherwise.
Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure.
"""
if FEISHU_AVAILABLE:
return _probe_bot_sdk(app_id, app_secret, domain)
return _probe_bot_http(app_id, app_secret, domain)
def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any:
"""Build a lark Client for the given credentials and domain."""
sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN
return (
lark.Client.builder()
.app_id(app_id)
.app_secret(app_secret)
.domain(sdk_domain)
.log_level(lark.LogLevel.WARNING)
.build()
)
def _parse_bot_response(data: dict) -> Optional[dict]:
"""Extract bot_name and bot_open_id from a /bot/v3/info response."""
if data.get("code") != 0:
return None
bot = data.get("bot") or data.get("data", {}).get("bot") or {}
return {
"bot_name": bot.get("bot_name"),
"bot_open_id": bot.get("open_id"),
}
def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
"""Probe bot info using lark_oapi SDK."""
try:
client = _build_onboard_client(app_id, app_secret, domain)
resp = client.request(
method="GET",
url="/open-apis/bot/v3/info",
body=None,
raw_response=True,
)
return _parse_bot_response(json.loads(resp.content))
except Exception as exc:
logger.debug("[Feishu onboard] SDK probe failed: %s", exc)
return None
def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
"""Fallback probe using raw HTTP (when lark_oapi is not installed)."""
base_url = _onboard_open_base_url(domain)
try:
token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8")
token_req = Request(
f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
data=token_data,
headers={"Content-Type": "application/json"},
)
with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
token_res = json.loads(resp.read().decode("utf-8"))
access_token = token_res.get("tenant_access_token")
if not access_token:
return None
bot_req = Request(
f"{base_url}/open-apis/bot/v3/info",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
)
with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
bot_res = json.loads(resp.read().decode("utf-8"))
return _parse_bot_response(bot_res)
except (URLError, OSError, KeyError, json.JSONDecodeError) as exc:
logger.debug("[Feishu onboard] HTTP probe failed: %s", exc)
return None
def qr_register(
*,
initial_domain: str = "feishu",
timeout_seconds: int = 600,
) -> Optional[dict]:
"""Run the Feishu / Lark scan-to-create QR registration flow.
Returns on success::
{
"app_id": str,
"app_secret": str,
"domain": "feishu" | "lark",
"open_id": str | None,
"bot_name": str | None,
"bot_open_id": str | None,
}
Returns None on expected failures (network, auth denied, timeout).
Unexpected errors (bugs, protocol regressions) propagate to the caller.
"""
try:
return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds)
except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc:
logger.warning("[Feishu onboard] Registration failed: %s", exc)
return None
def _qr_register_inner(
*,
initial_domain: str,
timeout_seconds: int,
) -> Optional[dict]:
"""Run init → begin → poll → probe. Raises on network/protocol errors."""
print(" Connecting to Feishu / Lark...", end="", flush=True)
_init_registration(initial_domain)
begin = _begin_registration(initial_domain)
print(" done.")
print()
qr_url = begin["qr_url"]
if _render_qr(qr_url):
print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}")
else:
print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n")
print(" Tip: pip install qrcode to display a scannable QR code here next time")
print()
result = _poll_registration(
device_code=begin["device_code"],
interval=begin["interval"],
expire_in=min(begin["expire_in"], timeout_seconds),
domain=initial_domain,
)
if not result:
return None
# Probe bot — best-effort, don't fail the registration
bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"])
if bot_info:
result["bot_name"] = bot_info.get("bot_name")
result["bot_open_id"] = bot_info.get("bot_open_id")
else:
result["bot_name"] = None
result["bot_open_id"] = None
return result
+23 -63
View File
@@ -25,7 +25,6 @@ Environment variables:
from __future__ import annotations
import asyncio
import json
import logging
import mimetypes
import os
@@ -782,7 +781,7 @@ class MatrixAdapter(BasePlatformAdapter):
# Try aiohttp first (always available), fall back to httpx
try:
import aiohttp as _aiohttp
async with _aiohttp.ClientSession() as http:
async with _aiohttp.ClientSession(trust_env=True) as http:
async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp:
resp.raise_for_status()
data = await resp.read()
@@ -1135,7 +1134,10 @@ class MatrixAdapter(BasePlatformAdapter):
thread_id = relates_to.get("event_id")
formatted_body = source_content.get("formatted_body")
is_mentioned = self._is_bot_mentioned(body, formatted_body)
# m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal.
mentions_block = source_content.get("m.mentions") or {}
mention_user_ids = mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None
is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids)
# Require-mention gating.
if not is_dm:
@@ -1609,52 +1611,6 @@ class MatrixAdapter(BasePlatformAdapter):
logger.warning("Matrix: redact error: %s", exc)
return False
# ------------------------------------------------------------------
# Room history
# ------------------------------------------------------------------
async def fetch_room_history(
self,
room_id: str,
limit: int = 50,
start: str = "",
) -> list:
"""Fetch recent messages from a room."""
if not self._client:
return []
try:
resp = await self._client.get_messages(
RoomID(room_id),
direction=PaginationDirection.BACKWARD,
from_token=SyncToken(start) if start else None,
limit=limit,
)
except Exception as exc:
logger.warning("Matrix: get_messages failed for %s: %s", room_id, exc)
return []
if not resp:
return []
events = getattr(resp, "chunk", []) or (resp.get("chunk", []) if isinstance(resp, dict) else [])
messages = []
for event in reversed(events):
body = ""
content = getattr(event, "content", None)
if content:
if hasattr(content, "body"):
body = content.body or ""
elif isinstance(content, dict):
body = content.get("body", "")
messages.append({
"event_id": str(getattr(event, "event_id", "")),
"sender": str(getattr(event, "sender", "")),
"body": body,
"timestamp": getattr(event, "timestamp", 0) or getattr(event, "server_timestamp", 0),
"type": type(event).__name__,
})
return messages
# ------------------------------------------------------------------
# Room creation & management
# ------------------------------------------------------------------
@@ -1758,18 +1714,6 @@ class MatrixAdapter(BasePlatformAdapter):
except Exception as exc:
return SendResult(success=False, error=str(exc))
async def send_emote(
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an emote message (/me style action)."""
return await self._send_simple_message(chat_id, text, "m.emote")
async def send_notice(
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a notice message (bot-appropriate, non-alerting)."""
return await self._send_simple_message(chat_id, text, "m.notice")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@@ -1822,8 +1766,24 @@ class MatrixAdapter(BasePlatformAdapter):
# Mention detection helpers
# ------------------------------------------------------------------
def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool:
"""Return True if the bot is mentioned in the message."""
def _is_bot_mentioned(
self,
body: str,
formatted_body: Optional[str] = None,
mention_user_ids: Optional[list] = None,
) -> bool:
"""Return True if the bot is mentioned in the message.
Per MSC3952, ``m.mentions.user_ids`` is the authoritative mention
signal in the Matrix spec. When the sender's client populates that
field with the bot's user-id, we trust it — even when the visible
body text does not contain an explicit ``@bot`` string (some clients
only render mention "pills" in ``formatted_body`` or use display
names).
"""
# m.mentions.user_ids — authoritative per MSC3952 / Matrix v1.7.
if mention_user_ids and self._user_id and self._user_id in mention_user_ids:
return True
if not body and not formatted_body:
return False
if self._user_id and self._user_id in body:
-16
View File
@@ -17,7 +17,6 @@ import json
import logging
import os
import random
import re
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -781,21 +780,6 @@ class SignalAdapter(BasePlatformAdapter):
# Typing Indicators
# ------------------------------------------------------------------
async def _start_typing_indicator(self, chat_id: str) -> None:
"""Start a typing indicator loop for a chat."""
if chat_id in self._typing_tasks:
return # Already running
async def _typing_loop():
try:
while True:
await self.send_typing(chat_id)
await asyncio.sleep(TYPING_INTERVAL)
except asyncio.CancelledError:
pass
self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
async def _stop_typing_indicator(self, chat_id: str) -> None:
"""Stop a typing indicator loop for a chat."""
task = self._typing_tasks.pop(chat_id, None)
+16 -10
View File
@@ -65,7 +65,10 @@ from gateway.platforms.base import (
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
resolve_proxy_url,
SUPPORTED_DOCUMENT_TYPES,
utf16_len,
_prefix_within_utf16_limit,
)
from gateway.platforms.telegram_network import (
TelegramFallbackTransport,
@@ -537,10 +540,7 @@ class TelegramAdapter(BasePlatformAdapter):
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
}
proxy_configured = any(
(os.getenv(k) or "").strip()
for k in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
)
proxy_url = resolve_proxy_url()
disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on"))
fallback_ips = self._fallback_ips()
if not fallback_ips:
@@ -551,7 +551,7 @@ class TelegramAdapter(BasePlatformAdapter):
", ".join(fallback_ips),
)
if fallback_ips and not proxy_configured and not disable_fallback:
if fallback_ips and not proxy_url and not disable_fallback:
logger.info(
"[%s] Telegram fallback IPs active: %s",
self.name,
@@ -567,10 +567,12 @@ class TelegramAdapter(BasePlatformAdapter):
**request_kwargs,
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
)
elif proxy_url:
logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url)
request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
else:
if proxy_configured:
logger.info("[%s] Proxy configured; skipping Telegram fallback-IP transport", self.name)
elif disable_fallback:
if disable_fallback:
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
request = HTTPXRequest(**request_kwargs)
get_updates_request = HTTPXRequest(**request_kwargs)
@@ -799,7 +801,9 @@ class TelegramAdapter(BasePlatformAdapter):
try:
# Format and split message if needed
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
chunks = self.truncate_message(
formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
)
if len(chunks) > 1:
# truncate_message appends a raw " (1/2)" suffix. Escape the
# MarkdownV2-special parentheses so Telegram doesn't reject the
@@ -970,7 +974,9 @@ class TelegramAdapter(BasePlatformAdapter):
# streaming). Truncate and succeed so the stream consumer can
# split the overflow into a new message instead of dying.
if "message_too_long" in err_str or "too long" in err_str:
truncated = content[: self.MAX_MESSAGE_LENGTH - 20] + ""
truncated = _prefix_within_utf16_limit(
content, self.MAX_MESSAGE_LENGTH - 20
) + ""
try:
await self._bot.edit_message_text(
chat_id=int(chat_id),
-1
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import ipaddress
import logging
import os
import socket
from typing import Iterable, Optional
-1
View File
@@ -27,7 +27,6 @@ import hashlib
import hmac
import json
import logging
import os
import re
import subprocess
import time
+1 -2
View File
@@ -37,7 +37,6 @@ import logging
import mimetypes
import os
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -266,7 +265,7 @@ class WeComAdapter(BasePlatformAdapter):
async def _open_connection(self) -> None:
"""Open and authenticate a websocket connection."""
await self._cleanup_ws()
self._session = aiohttp.ClientSession()
self._session = aiohttp.ClientSession(trust_env=True)
self._ws = await self._session.ws_connect(
self._ws_url,
heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2,
+103 -50
View File
@@ -112,6 +112,7 @@ TYPING_STOP = 2
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
_TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$")
_FENCE_RE = re.compile(r"^```([^\n`]*)\s*$")
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def check_weixin_requirements() -> bool:
@@ -398,15 +399,16 @@ async def _send_message(
context_token: Optional[str],
client_id: str,
) -> None:
if not text or not text.strip():
raise ValueError("_send_message: text must not be empty")
message: Dict[str, Any] = {
"from_user_id": "",
"to_user_id": to,
"client_id": client_id,
"message_type": MSG_TYPE_BOT,
"message_state": MSG_STATE_FINISH,
"item_list": [{"type": ITEM_TEXT, "text_item": {"text": text}}],
}
if text:
message["item_list"] = [{"type": ITEM_TEXT, "text_item": {"text": text}}]
if context_token:
message["context_token"] = context_token
await _api_post(
@@ -499,13 +501,15 @@ async def _upload_ciphertext(
session: "aiohttp.ClientSession",
*,
ciphertext: bytes,
cdn_base_url: str,
upload_param: str,
filekey: str,
upload_url: str,
) -> str:
url = _cdn_upload_url(cdn_base_url, upload_param, filekey)
"""Upload encrypted media to the CDN.
Accepts either a constructed CDN URL (from upload_param) or a direct
upload_full_url both use POST with the raw ciphertext as the body.
"""
timeout = aiohttp.ClientTimeout(total=120)
async with session.post(url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
if response.status == 200:
encrypted_param = response.headers.get("x-encrypted-param")
if encrypted_param:
@@ -649,7 +653,7 @@ def _normalize_markdown_blocks(content: str) -> str:
result.append(_rewrite_table_block_for_weixin(table_lines))
continue
result.append(_rewrite_headers_for_weixin(line))
result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line)))
i += 1
normalized = "\n".join(item.rstrip() for item in result)
@@ -811,6 +815,8 @@ def _split_text_for_weixin_delivery(
``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``)
or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``.
"""
if not content:
return []
if split_per_line:
# Legacy: one message per top-level delivery unit.
if len(content) <= max_length and "\n" not in content:
@@ -821,14 +827,14 @@ def _split_text_for_weixin_delivery(
chunks.append(unit)
continue
chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
return chunks or [content]
return [c for c in chunks if c] or [content]
# 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 (
_split_delivery_units_for_weixin(content)
[u for u in _split_delivery_units_for_weixin(content) if u]
if _should_split_short_chat_block_for_weixin(content)
else [content]
)
@@ -929,7 +935,7 @@ async def qr_login(
if not AIOHTTP_AVAILABLE:
raise RuntimeError("aiohttp is required for Weixin QR login")
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
try:
qr_resp = await _api_get(
session,
@@ -1042,6 +1048,10 @@ class WeixinAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 4000
# WeChat does not support editing sent messages — streaming must use the
# fallback "send-final-only" path so the cursor (▉) is never left visible.
SUPPORTS_MESSAGE_EDITING = False
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WEIXIN)
extra = config.extra or {}
@@ -1124,7 +1134,7 @@ class WeixinAdapter(BasePlatformAdapter):
except Exception as exc:
logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc)
self._session = aiohttp.ClientSession()
self._session = aiohttp.ClientSession(trust_env=True)
self._token_store.restore(self._account_id)
self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll")
self._mark_connected()
@@ -1451,7 +1461,7 @@ class WeixinAdapter(BasePlatformAdapter):
context_token = self._token_store.get(self._account_id, chat_id)
last_message_id: Optional[str] = None
try:
chunks = self._split_text(self.format_message(content))
chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()]
for idx, chunk in enumerate(chunks):
client_id = f"hermes-weixin-{uuid.uuid4().hex}"
await self._send_text_chunk(
@@ -1537,24 +1547,51 @@ class WeixinAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self.send_document(chat_id, path, caption=caption, metadata=metadata)
return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata)
async def send_document(
self,
chat_id: str,
path: str,
file_path: str,
caption: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if not self._session or not self._token:
return SendResult(success=False, error="Not connected")
try:
message_id = await self._send_file(chat_id, path, caption)
message_id = await self._send_file(chat_id, file_path, caption)
return SendResult(success=True, message_id=message_id)
except Exception as exc:
logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if not self._session or not self._token:
return SendResult(success=False, error="Not connected")
try:
message_id = await self._send_file(chat_id, video_path, caption or "")
return SendResult(success=True, message_id=message_id)
except Exception as exc:
logger.error("[%s] send_video failed to=%s: %s", self.name, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self.send_document(chat_id, audio_path, caption=caption or "", metadata=metadata)
async def _download_remote_media(self, url: str) -> str:
from tools.url_safety import is_safe_url
@@ -1577,6 +1614,7 @@ class WeixinAdapter(BasePlatformAdapter):
filekey = secrets.token_hex(16)
aes_key = secrets.token_bytes(16)
rawsize = len(plaintext)
rawfilemd5 = hashlib.md5(plaintext).hexdigest()
upload_response = await _get_upload_url(
self._session,
base_url=self._base_url,
@@ -1585,41 +1623,42 @@ class WeixinAdapter(BasePlatformAdapter):
media_type=media_type,
filekey=filekey,
rawsize=rawsize,
rawfilemd5=hashlib.md5(plaintext).hexdigest(),
rawfilemd5=rawfilemd5,
filesize=_aes_padded_size(rawsize),
aeskey_hex=aes_key.hex(),
)
upload_param = str(upload_response.get("upload_param") or "")
upload_full_url = str(upload_response.get("upload_full_url") or "")
ciphertext = _aes128_ecb_encrypt(plaintext, aes_key)
if upload_param:
encrypted_query_param = await _upload_ciphertext(
self._session,
ciphertext=ciphertext,
cdn_base_url=self._cdn_base_url,
upload_param=upload_param,
filekey=filekey,
)
elif upload_full_url:
timeout = aiohttp.ClientTimeout(total=120)
async with self._session.put(
upload_full_url,
data=ciphertext,
headers={"Content-Type": "application/octet-stream"},
timeout=timeout,
) as response:
response.raise_for_status()
encrypted_query_param = response.headers.get("x-encrypted-param") or filekey
# Prefer upload_full_url (direct CDN), fall back to constructed CDN URL
# from upload_param. Both paths use POST — the old PUT for
# upload_full_url caused 404s on the WeChat CDN.
if upload_full_url:
upload_url = upload_full_url
elif upload_param:
upload_url = _cdn_upload_url(self._cdn_base_url, upload_param, filekey)
else:
raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}")
encrypted_query_param = await _upload_ciphertext(
self._session,
ciphertext=ciphertext,
upload_url=upload_url,
)
context_token = self._token_store.get(self._account_id, chat_id)
# The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes).
# Sending base64(raw_bytes) causes images to show as grey boxes on the
# receiver side because the decryption key doesn't match.
aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
media_item = item_builder(
encrypt_query_param=encrypted_query_param,
aes_key_b64=base64.b64encode(aes_key).decode("ascii"),
aes_key_for_api=aes_key_for_api,
ciphertext_size=len(ciphertext),
plaintext_size=rawsize,
filename=Path(path).name,
rawfilemd5=rawfilemd5,
)
last_message_id = None
@@ -1659,39 +1698,53 @@ class WeixinAdapter(BasePlatformAdapter):
def _outbound_media_builder(self, path: str):
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
if mime.startswith("image/"):
return MEDIA_IMAGE, lambda **kwargs: {
return MEDIA_IMAGE, lambda **kw: {
"type": ITEM_IMAGE,
"image_item": {
"media": {
"encrypt_query_param": kwargs["encrypt_query_param"],
"aes_key": kwargs["aes_key_b64"],
"encrypt_query_param": kw["encrypt_query_param"],
"aes_key": kw["aes_key_for_api"],
"encrypt_type": 1,
},
"mid_size": kwargs["ciphertext_size"],
"mid_size": kw["ciphertext_size"],
},
}
if mime.startswith("video/"):
return MEDIA_VIDEO, lambda **kwargs: {
return MEDIA_VIDEO, lambda **kw: {
"type": ITEM_VIDEO,
"video_item": {
"media": {
"encrypt_query_param": kwargs["encrypt_query_param"],
"aes_key": kwargs["aes_key_b64"],
"encrypt_query_param": kw["encrypt_query_param"],
"aes_key": kw["aes_key_for_api"],
"encrypt_type": 1,
},
"video_size": kwargs["ciphertext_size"],
"video_size": kw["ciphertext_size"],
"play_length": kw.get("play_length", 0),
"video_md5": kw.get("rawfilemd5", ""),
},
}
return MEDIA_FILE, lambda **kwargs: {
if mime.startswith("audio/") or path.endswith(".silk"):
return MEDIA_VOICE, lambda **kw: {
"type": ITEM_VOICE,
"voice_item": {
"media": {
"encrypt_query_param": kw["encrypt_query_param"],
"aes_key": kw["aes_key_for_api"],
"encrypt_type": 1,
},
"playtime": kw.get("playtime", 0),
},
}
return MEDIA_FILE, lambda **kw: {
"type": ITEM_FILE,
"file_item": {
"media": {
"encrypt_query_param": kwargs["encrypt_query_param"],
"aes_key": kwargs["aes_key_b64"],
"encrypt_query_param": kw["encrypt_query_param"],
"aes_key": kw["aes_key_for_api"],
"encrypt_type": 1,
},
"file_name": kwargs["filename"],
"len": str(kwargs["plaintext_size"]),
"file_name": kw["filename"],
"len": str(kw["plaintext_size"]),
},
}
@@ -1731,7 +1784,7 @@ async def send_weixin_direct(
token_store.restore(account_id)
context_token = token_store.get(account_id, chat_id)
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
adapter = WeixinAdapter(
PlatformConfig(
enabled=True,
+103 -26
View File
@@ -120,8 +120,9 @@ class WhatsAppAdapter(BasePlatformAdapter):
- session_path: Path to store WhatsApp session data
"""
# WhatsApp message limits
MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH = 4096
# Default bridge location relative to the hermes-agent install
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
@@ -531,6 +532,63 @@ class WhatsAppAdapter(BasePlatformAdapter):
self._close_bridge_log()
print(f"[{self.name}] Disconnected")
def format_message(self, content: str) -> str:
"""Convert standard markdown to WhatsApp-compatible formatting.
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
and monospaced `inline`. Standard markdown uses different syntax
for bold/italic/strikethrough, so we convert here.
Code blocks (``` fenced) and inline code (`) are protected from
conversion via placeholder substitution.
"""
if not content:
return content
# --- 1. Protect fenced code blocks from formatting changes ---
_FENCE_PH = "\x00FENCE"
fences: list[str] = []
def _save_fence(m: re.Match) -> str:
fences.append(m.group(0))
return f"{_FENCE_PH}{len(fences) - 1}\x00"
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
# --- 2. Protect inline code ---
_CODE_PH = "\x00CODE"
codes: list[str] = []
def _save_code(m: re.Match) -> str:
codes.append(m.group(0))
return f"{_CODE_PH}{len(codes) - 1}\x00"
result = re.sub(r"`[^`\n]+`", _save_code, result)
# --- 3. Convert markdown formatting to WhatsApp syntax ---
# Bold: **text** or __text__ → *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
result = re.sub(r"__(.+?)__", r"*\1*", result)
# Strikethrough: ~~text~~ → ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
# Italic: *text* is already WhatsApp italic — leave as-is
# _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text ---
# # Header → *Header*
result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE)
# --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
# --- 6. Restore protected sections ---
for i, fence in enumerate(fences):
result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
for i, code in enumerate(codes):
result = result.replace(f"{_CODE_PH}{i}\x00", code)
return result
async def send(
self,
chat_id: str,
@@ -538,38 +596,57 @@ class WhatsAppAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> SendResult:
"""Send a message via the WhatsApp bridge."""
"""Send a message via the WhatsApp bridge.
Formats markdown for WhatsApp, splits long messages into chunks
that preserve code block boundaries, and sends each chunk sequentially.
"""
if not self._running or not self._http_session:
return SendResult(success=False, error="Not connected")
bridge_exit = await self._check_managed_bridge_exit()
if bridge_exit:
return SendResult(success=False, error=bridge_exit)
if not content or not content.strip():
return SendResult(success=True, message_id=None)
try:
import aiohttp
payload = {
"chatId": chat_id,
"message": content,
}
if reply_to:
payload["replyTo"] = reply_to
async with self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/send",
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
data = await resp.json()
return SendResult(
success=True,
message_id=data.get("messageId"),
raw_response=data
)
else:
error = await resp.text()
return SendResult(success=False, error=error)
# Format and chunk the message
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
last_message_id = None
for chunk in chunks:
payload: Dict[str, Any] = {
"chatId": chat_id,
"message": chunk,
}
if reply_to and last_message_id is None:
# Only reply-to on the first chunk
payload["replyTo"] = reply_to
async with self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/send",
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
data = await resp.json()
last_message_id = data.get("messageId")
else:
error = await resp.text()
return SendResult(success=False, error=error)
# Small delay between chunks to avoid rate limiting
if len(chunks) > 1:
await asyncio.sleep(0.3)
return SendResult(
success=True,
message_id=last_message_id,
)
except Exception as e:
return SendResult(success=False, error=str(e))
+222 -55
View File
@@ -186,6 +186,8 @@ if _config_path.exists():
os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"])
if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ:
os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"])
if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ:
os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"])
if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ:
os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"])
_display_cfg = _cfg.get("display", {})
@@ -1715,6 +1717,9 @@ class GatewayRunner:
):
self._schedule_update_notification_watch()
# Notify the chat that initiated /restart that the gateway is back.
await self._send_restart_notification()
# Drain any recovered process watchers (from crash recovery checkpoint)
try:
from tools.process_registry import process_registry
@@ -2541,11 +2546,8 @@ class GatewayRunner:
self._pending_messages.pop(_quick_key, None)
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
# Mark session suspended so the next message starts fresh
# instead of resuming the stuck context (#7536).
self.session_store.suspend_session(_quick_key)
logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20])
return "⚡ Force-stopped. The session is suspended — your next message will start fresh."
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
return "⚡ Stopped. You can continue this session."
# /reset and /new must bypass the running-agent guard so they
# actually dispatch as commands instead of being queued as user
@@ -2755,6 +2757,9 @@ class GatewayRunner:
if canonical == "update":
return await self._handle_update_command(event)
if canonical == "debug":
return await self._handle_debug_command(event)
if canonical == "title":
return await self._handle_title_command(event)
@@ -3322,21 +3327,26 @@ class GatewayRunner:
# Must run after runtime resolution so _hyg_base_url is set.
if _hyg_config_context_length is None and _hyg_base_url:
try:
_hyg_custom_providers = _hyg_data.get("custom_providers")
if isinstance(_hyg_custom_providers, list):
for _cp in _hyg_custom_providers:
if not isinstance(_cp, dict):
continue
_cp_url = (_cp.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
_cp_models = _cp.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(_hyg_model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
_hyg_config_context_length = int(_cp_ctx)
break
try:
from hermes_cli.config import get_compatible_custom_providers as _gw_gcp
_hyg_custom_providers = _gw_gcp(_hyg_data)
except Exception:
_hyg_custom_providers = _hyg_data.get("custom_providers")
if not isinstance(_hyg_custom_providers, list):
_hyg_custom_providers = []
for _cp in _hyg_custom_providers:
if not isinstance(_cp, dict):
continue
_cp_url = (_cp.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
_cp_models = _cp.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(_hyg_model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
_hyg_config_context_length = int(_cp_ctx)
break
except (TypeError, ValueError):
pass
except Exception:
@@ -4107,9 +4117,7 @@ class GatewayRunner:
only through normal command dispatch (no running agent) or as a
fallback. Force-clean the session lock in all cases for safety.
When there IS a running/pending agent, the session is also marked
as *suspended* so the next message starts a fresh session instead
of resuming the stuck context (#7536).
The session is preserved so the user can continue the conversation.
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
@@ -4120,17 +4128,15 @@ class GatewayRunner:
# Force-clean the sentinel so the session is unlocked.
if session_key in self._running_agents:
del self._running_agents[session_key]
self.session_store.suspend_session(session_key)
logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20])
return "⚡ Force-stopped. The agent was still starting — your next message will start fresh."
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
if agent:
agent.interrupt("Stop requested")
# Force-clean the session lock so a truly hung agent doesn't
# keep it locked forever.
if session_key in self._running_agents:
del self._running_agents[session_key]
self.session_store.suspend_session(session_key)
return "⚡ Force-stopped. Your next message will start a fresh session."
return "⚡ Stopped. You can continue this session."
else:
return "No active task to stop."
@@ -4142,11 +4148,36 @@ class GatewayRunner:
return f"⏳ Draining {count} active agent(s) before restart..."
return "⏳ Gateway restart already in progress..."
# Save the requester's routing info so the new gateway process can
# notify them once it comes back online.
try:
import json as _json
notify_data = {
"platform": event.source.platform.value if event.source.platform else None,
"chat_id": event.source.chat_id,
}
if event.source.thread_id:
notify_data["thread_id"] = event.source.thread_id
(_hermes_home / ".restart_notify.json").write_text(
_json.dumps(notify_data)
)
except Exception as e:
logger.debug("Failed to write restart notify file: %s", e)
active_agents = self._running_agent_count()
self.request_restart(detached=True, via_service=False)
# When running under a service manager (systemd/launchd), use the
# service restart path: exit with code 75 so the service manager
# restarts us. The detached subprocess approach (setsid + bash)
# doesn't work under systemd because KillMode=mixed kills all
# processes in the cgroup, including the detached helper.
_under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
if _under_service:
self.request_restart(detached=False, via_service=True)
else:
self.request_restart(detached=True, via_service=False)
if active_agents:
return f"⏳ Draining {active_agents} active agent(s) before restart..."
return "♻ Restarting gateway..."
return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`."
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
@@ -4263,7 +4294,11 @@ class GatewayRunner:
current_provider = model_cfg.get("provider", current_provider)
current_base_url = model_cfg.get("base_url", "")
user_provs = cfg.get("providers")
custom_provs = cfg.get("custom_providers")
try:
from hermes_cli.config import get_compatible_custom_providers
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
custom_provs = cfg.get("custom_providers")
except Exception:
pass
@@ -4894,6 +4929,8 @@ class GatewayRunner:
if success:
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
if hasattr(adapter, "_voice_sources"):
adapter._voice_sources[guild_id] = event.source.to_dict()
self._voice_mode[event.source.chat_id] = "all"
self._save_voice_modes()
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
@@ -4954,14 +4991,23 @@ class GatewayRunner:
if not text_ch_id:
return
# Build source — reuse the linked text channel's metadata when available
# so voice input shares the same session as the bound text conversation.
source_data = getattr(adapter, "_voice_sources", {}).get(guild_id)
if source_data:
source = SessionSource.from_dict(source_data)
source.user_id = str(user_id)
source.user_name = str(user_id)
else:
source = SessionSource(
platform=Platform.DISCORD,
chat_id=str(text_ch_id),
user_id=str(user_id),
user_name=str(user_id),
chat_type="channel",
)
# Check authorization before processing voice input
source = SessionSource(
platform=Platform.DISCORD,
chat_id=str(text_ch_id),
user_id=str(user_id),
user_name=str(user_id),
chat_type="channel",
)
if not self._is_user_authorized(source):
logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
return
@@ -6250,7 +6296,7 @@ class GatewayRunner:
"""Handle /reload-mcp command -- disconnect and reconnect all MCP servers."""
loop = asyncio.get_event_loop()
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
# Capture old server names before shutdown
with _lock:
@@ -6426,6 +6472,61 @@ class GatewayRunner:
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
})
async def _handle_debug_command(self, event: MessageEvent) -> str:
"""Handle /debug — upload debug report + logs and return paste URLs."""
import asyncio
from hermes_cli.debug import (
_capture_dump, collect_debug_report, _read_full_log,
upload_to_pastebin,
)
loop = asyncio.get_running_loop()
# Run blocking I/O (dump capture, log reads, uploads) in a thread.
def _collect_and_upload():
dump_text = _capture_dump()
report = collect_debug_report(log_lines=200, dump_text=dump_text)
agent_log = _read_full_log("agent")
gateway_log = _read_full_log("gateway")
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
urls = {}
failures = []
try:
urls["Report"] = upload_to_pastebin(report)
except Exception as exc:
return f"✗ Failed to upload debug report: {exc}"
if agent_log:
try:
urls["agent.log"] = upload_to_pastebin(agent_log)
except Exception:
failures.append("agent.log")
if gateway_log:
try:
urls["gateway.log"] = upload_to_pastebin(gateway_log)
except Exception:
failures.append("gateway.log")
lines = ["**Debug report uploaded:**", ""]
label_width = max(len(k) for k in urls)
for label, url in urls.items():
lines.append(f"`{label:<{label_width}}` {url}")
if failures:
lines.append(f"\n_(failed to upload: {', '.join(failures)})_")
lines.append("\nShare these links with the Hermes team for support.")
return "\n".join(lines)
return await loop.run_in_executor(None, _collect_and_upload)
async def _handle_update_command(self, event: MessageEvent) -> str:
"""Handle /update command — update Hermes Agent to the latest version.
@@ -6820,6 +6921,48 @@ class GatewayRunner:
return True
async def _send_restart_notification(self) -> None:
"""Notify the chat that initiated /restart that the gateway is back."""
import json as _json
notify_path = _hermes_home / ".restart_notify.json"
if not notify_path.exists():
return
try:
data = _json.loads(notify_path.read_text())
platform_str = data.get("platform")
chat_id = data.get("chat_id")
thread_id = data.get("thread_id")
if not platform_str or not chat_id:
return
platform = Platform(platform_str)
adapter = self.adapters.get(platform)
if not adapter:
logger.debug(
"Restart notification skipped: %s adapter not connected",
platform_str,
)
return
metadata = {"thread_id": thread_id} if thread_id else None
await adapter.send(
chat_id,
"♻ Gateway restarted successfully. Your session continues.",
metadata=metadata,
)
logger.info(
"Sent restart notification to %s:%s",
platform_str,
chat_id,
)
except Exception as e:
logger.warning("Restart notification failed: %s", e)
finally:
notify_path.unlink(missing_ok=True)
def _set_session_env(self, context: SessionContext) -> list:
"""Set session context variables for the current async task.
@@ -7351,9 +7494,11 @@ class GatewayRunner:
_pl = get_tool_preview_max_len()
import json as _json
args_str = _json.dumps(args, ensure_ascii=False, default=str)
_cap = _pl if _pl > 0 else 200
if len(args_str) > _cap:
args_str = args_str[:_cap - 3] + "..."
# When tool_preview_length is 0 (default), don't truncate
# in verbose mode — the user explicitly asked for full
# detail. Platform message-length limits handle the rest.
if _pl > 0 and len(args_str) > _pl:
args_str = args_str[:_pl - 3] + "..."
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
elif preview:
msg = f"{emoji} {tool_name}: \"{preview}\""
@@ -7663,10 +7808,23 @@ class GatewayRunner:
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
_adapter = self.adapters.get(source.platform)
if _adapter:
# Platforms that don't support editing sent messages
# (e.g. WeChat) must not show a cursor in intermediate
# sends — the cursor would be permanently visible because
# it can never be edited away. Use an empty cursor for
# such platforms so streaming still delivers the final
# response, just without the typing indicator.
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
# Some Matrix clients render the streaming cursor
# as a visible tofu/white-box artifact. Keep
# streaming text on Matrix, but suppress the cursor.
if source.platform == Platform.MATRIX:
_effective_cursor = ""
_consumer_cfg = StreamConsumerConfig(
edit_interval=_scfg.edit_interval,
buffer_threshold=_scfg.buffer_threshold,
cursor=_scfg.cursor,
cursor=_effective_cursor,
)
_stream_consumer = GatewayStreamConsumer(
adapter=_adapter,
@@ -8146,11 +8304,17 @@ class GatewayRunner:
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
# Periodic "still working" notifications for long-running tasks.
# Fires every 10 minutes so the user knows the agent hasn't died.
_NOTIFY_INTERVAL = 600 # 10 minutes
# Fires every N seconds so the user knows the agent hasn't died.
# Config: agent.gateway_notify_interval in config.yaml, or
# HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min).
# 0 = disable notifications.
_NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600))
_NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None
_notify_start = time.time()
async def _notify_long_running():
if _NOTIFY_INTERVAL is None:
return # Notifications disabled (gateway_notify_interval: 0)
_notify_adapter = self.adapters.get(source.platform)
if not _notify_adapter:
return
@@ -8745,16 +8909,19 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
runner.request_restart(detached=False, via_service=True)
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, shutdown_signal_handler)
except NotImplementedError:
pass
if hasattr(signal, "SIGUSR1"):
try:
loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler)
except NotImplementedError:
pass
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, shutdown_signal_handler)
except NotImplementedError:
pass
if hasattr(signal, "SIGUSR1"):
try:
loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler)
except NotImplementedError:
pass
else:
logger.info("Skipping signal handlers (not running in main thread).")
# Start the gateway
success = await runner.start()
+8 -2
View File
@@ -12,7 +12,6 @@ import hashlib
import logging
import os
import json
import re
import threading
import uuid
from pathlib import Path
@@ -878,7 +877,8 @@ class SessionStore:
Used by ``/resume`` to restore a previously-named session.
Ends the current session in SQLite (like reset), but instead of
generating a fresh session ID, re-uses ``target_session_id`` so the
old transcript is loaded on the next message.
old transcript is loaded on the next message. If the target session was
previously ended, re-open it so gateway resume semantics match the CLI.
"""
db_end_session_id = None
new_entry = None
@@ -918,6 +918,12 @@ class SessionStore:
except Exception as e:
logger.debug("Session DB end_session failed: %s", e)
if self._db:
try:
self._db.reopen_session(target_session_id)
except Exception as e:
logger.debug("Session DB reopen_session failed: %s", e)
return new_entry
def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]:
+9
View File
@@ -290,6 +290,15 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
}
existing = _read_json_file(lock_path)
if existing is None and lock_path.exists():
# Lock file exists but is empty or contains invalid JSON — treat as
# stale. This happens when a previous process was killed between
# O_CREAT|O_EXCL and the subsequent json.dump() (e.g. DNS failure
# during rapid Slack reconnect retries).
try:
lock_path.unlink(missing_ok=True)
except OSError:
pass
if existing:
try:
existing_pid = int(existing["pid"])
+7
View File
@@ -491,6 +491,13 @@ class GatewayStreamConsumer:
# Media files are delivered as native attachments after the stream
# finishes (via _deliver_media_from_response in gateway/run.py).
text = self._clean_for_display(text)
# A bare streaming cursor is not meaningful user-visible content and
# can render as a stray tofu/white-box message on some clients.
visible_without_cursor = text
if self.cfg.cursor:
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
if not visible_without_cursor.strip():
return True # cursor-only / whitespace-only update
if not text.strip():
return True # nothing to send is "success"
try:
+2 -2
View File
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
__version__ = "0.8.0"
__release_date__ = "2026.4.8"
__version__ = "0.9.0"
__release_date__ = "2026.4.13"
+52 -39
View File
@@ -127,6 +127,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
auth_type="api_key",
inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL,
api_key_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
base_url_env_var="COPILOT_API_BASE_URL",
),
"copilot-acp": ProviderConfig(
id="copilot-acp",
@@ -159,6 +160,21 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("KIMI_API_KEY",),
base_url_env_var="KIMI_BASE_URL",
),
"kimi-coding-cn": ProviderConfig(
id="kimi-coding-cn",
name="Kimi / Moonshot (China)",
auth_type="api_key",
inference_base_url="https://api.moonshot.cn/v1",
api_key_env_vars=("KIMI_CN_API_KEY",),
),
"arcee": ProviderConfig(
id="arcee",
name="Arcee AI",
auth_type="api_key",
inference_base_url="https://api.arcee.ai/api/v1",
api_key_env_vars=("ARCEEAI_API_KEY",),
base_url_env_var="ARCEE_BASE_URL",
),
"minimax": ProviderConfig(
id="minimax",
name="MiniMax",
@@ -307,44 +323,6 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
return default_url
def _gh_cli_candidates() -> list[str]:
"""Return candidate ``gh`` binary paths, including common Homebrew installs."""
candidates: list[str] = []
resolved = shutil.which("gh")
if resolved:
candidates.append(resolved)
for candidate in (
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
str(Path.home() / ".local" / "bin" / "gh"),
):
if candidate in candidates:
continue
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
candidates.append(candidate)
return candidates
def _try_gh_cli_token() -> Optional[str]:
"""Return a token from ``gh auth token`` when the GitHub CLI is available."""
for gh_path in _gh_cli_candidates():
try:
result = subprocess.run(
[gh_path, "auth", "token"],
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
continue
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
_PLACEHOLDER_SECRET_VALUES = {
"*",
@@ -929,6 +907,8 @@ def resolve_provider(
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"arcee-ai": "arcee", "arceeai": "arcee",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
"github": "copilot", "github-copilot": "copilot",
@@ -2282,7 +2262,40 @@ def resolve_nous_runtime_credentials(
# =============================================================================
def get_nous_auth_status() -> Dict[str, Any]:
"""Status snapshot for `hermes status` output."""
"""Status snapshot for `hermes status` output.
Checks the credential pool first (where the dashboard device-code flow
and ``hermes auth`` store credentials), then falls back to the legacy
auth-store provider state.
"""
# Check credential pool first — the dashboard device-code flow saves
# here but may not have written to the auth store yet.
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
access_token = (
getattr(entry, "access_token", None)
or getattr(entry, "runtime_api_key", "")
)
if access_token:
return {
"logged_in": True,
"portal_base_url": getattr(entry, "portal_base_url", None)
or getattr(entry, "base_url", None),
"inference_base_url": getattr(entry, "inference_base_url", None)
or getattr(entry, "base_url", None),
"access_token": access_token,
"access_expires_at": getattr(entry, "expires_at", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"has_refresh_token": bool(getattr(entry, "refresh_token", None)),
}
except Exception:
pass
# Fall back to auth-store provider state
state = get_provider_auth_state("nous")
if not state:
return {
+9 -9
View File
@@ -36,25 +36,23 @@ _OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth"}
def _get_custom_provider_names() -> list:
"""Return list of (display_name, pool_key) tuples for custom_providers in config."""
"""Return list of (display_name, pool_key, provider_key) tuples."""
try:
from hermes_cli.config import load_config
from hermes_cli.config import get_compatible_custom_providers, load_config
config = load_config()
except Exception:
return []
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
return []
result = []
for entry in custom_providers:
for entry in get_compatible_custom_providers(config):
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
pool_key = f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}"
result.append((name.strip(), pool_key))
provider_key = str(entry.get("provider_key", "") or "").strip()
result.append((name.strip(), pool_key, provider_key))
return result
@@ -66,9 +64,11 @@ def _resolve_custom_provider_input(raw: str) -> str | None:
# Direct match on 'custom:name' format
if normalized.startswith(CUSTOM_POOL_PREFIX):
return normalized
for display_name, pool_key in _get_custom_provider_names():
for display_name, pool_key, provider_key in _get_custom_provider_names():
if _normalize_custom_pool_name(display_name) == normalized:
return pool_key
if provider_key and provider_key.strip().lower() == normalized:
return pool_key
return None
@@ -405,7 +405,7 @@ def _pick_provider(prompt: str = "Provider") -> str:
known = sorted(set(list(PROVIDER_REGISTRY.keys()) + ["openrouter"]))
custom_names = _get_custom_provider_names()
if custom_names:
custom_display = [name for name, _key in custom_names]
custom_display = [name for name, _key, _provider_key in custom_names]
print(f"\nKnown providers: {', '.join(known)}")
print(f"Custom endpoints: {', '.join(custom_display)}")
else:
+261 -5
View File
@@ -8,14 +8,22 @@ Backup and import commands for hermes CLI.
HERMES_HOME root.
"""
import json
import logging
import os
import shutil
import sqlite3
import sys
import tempfile
import time
import zipfile
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_default_hermes_root, display_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_home, display_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -63,6 +71,33 @@ def _should_exclude(rel_path: Path) -> bool:
return False
# ---------------------------------------------------------------------------
# SQLite safe copy
# ---------------------------------------------------------------------------
def _safe_copy_db(src: Path, dst: Path) -> bool:
"""Copy a SQLite database safely using the backup() API.
Handles WAL mode produces a consistent snapshot even while
the DB is being written to. Falls back to raw copy on failure.
"""
try:
conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
backup_conn = sqlite3.connect(str(dst))
conn.backup(backup_conn)
backup_conn.close()
conn.close()
return True
except Exception as exc:
logger.warning("SQLite safe copy failed for %s: %s", src, exc)
try:
shutil.copy2(src, dst)
return True
except Exception as exc2:
logger.error("Raw copy also failed for %s: %s", src, exc2)
return False
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
@@ -151,8 +186,21 @@ def run_backup(args) -> None:
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
# Safe copy for SQLite databases (handles WAL mode)
if abs_path.suffix == ".db":
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_db = Path(tmp.name)
if _safe_copy_db(abs_path, tmp_db):
zf.write(tmp_db, arcname=str(rel_path))
total_bytes += tmp_db.stat().st_size
tmp_db.unlink(missing_ok=True)
else:
tmp_db.unlink(missing_ok=True)
errors.append(f" {rel_path}: SQLite safe copy failed")
continue
else:
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
@@ -201,7 +249,7 @@ def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
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"}
markers = {"config.yaml", ".env", "state.db"}
found = set()
for n in names:
# Could be at the root or one level deep (if someone zipped the directory)
@@ -397,3 +445,211 @@ def run_import(args) -> None:
print(f" hermes -p {pname} gateway install")
print("Done. Your Hermes configuration has been restored.")
# ---------------------------------------------------------------------------
# Quick state snapshots (used by /snapshot slash command and hermes backup --quick)
# ---------------------------------------------------------------------------
# Critical state files to include in quick snapshots (relative to HERMES_HOME).
# Everything else is either regeneratable (logs, cache) or managed separately
# (skills, repo, sessions/).
_QUICK_STATE_FILES = (
"state.db",
"config.yaml",
".env",
"auth.json",
"cron/jobs.json",
"gateway_state.json",
"channel_directory.json",
"processes.json",
)
_QUICK_SNAPSHOTS_DIR = "state-snapshots"
_QUICK_DEFAULT_KEEP = 20
def _quick_snapshot_root(hermes_home: Optional[Path] = None) -> Path:
home = hermes_home or get_hermes_home()
return home / _QUICK_SNAPSHOTS_DIR
def create_quick_snapshot(
label: Optional[str] = None,
hermes_home: Optional[Path] = None,
) -> Optional[str]:
"""Create a quick state snapshot of critical files.
Copies STATE_FILES to a timestamped directory under state-snapshots/.
Auto-prunes old snapshots beyond the keep limit.
Returns:
Snapshot ID (timestamp-based), or None if no files found.
"""
home = hermes_home or get_hermes_home()
root = _quick_snapshot_root(home)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
snap_id = f"{ts}-{label}" if label else ts
snap_dir = root / snap_id
snap_dir.mkdir(parents=True, exist_ok=True)
manifest: Dict[str, int] = {} # rel_path -> file size
for rel in _QUICK_STATE_FILES:
src = home / rel
if not src.exists() or not src.is_file():
continue
dst = snap_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
try:
if src.suffix == ".db":
if not _safe_copy_db(src, dst):
continue
else:
shutil.copy2(src, dst)
manifest[rel] = dst.stat().st_size
except (OSError, PermissionError) as exc:
logger.warning("Could not snapshot %s: %s", rel, exc)
if not manifest:
shutil.rmtree(snap_dir, ignore_errors=True)
return None
# Write manifest
meta = {
"id": snap_id,
"timestamp": ts,
"label": label,
"file_count": len(manifest),
"total_size": sum(manifest.values()),
"files": manifest,
}
with open(snap_dir / "manifest.json", "w") as f:
json.dump(meta, f, indent=2)
# Auto-prune
_prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP)
logger.info("State snapshot created: %s (%d files)", snap_id, len(manifest))
return snap_id
def list_quick_snapshots(
limit: int = 20,
hermes_home: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""List existing quick state snapshots, most recent first."""
root = _quick_snapshot_root(hermes_home)
if not root.exists():
return []
results = []
for d in sorted(root.iterdir(), reverse=True):
if not d.is_dir():
continue
manifest_path = d / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path) as f:
results.append(json.load(f))
except (json.JSONDecodeError, OSError):
results.append({"id": d.name, "file_count": 0, "total_size": 0})
if len(results) >= limit:
break
return results
def restore_quick_snapshot(
snapshot_id: str,
hermes_home: Optional[Path] = None,
) -> bool:
"""Restore state from a quick snapshot.
Overwrites current state files with the snapshot's copies.
Returns True if at least one file was restored.
"""
home = hermes_home or get_hermes_home()
root = _quick_snapshot_root(home)
snap_dir = root / snapshot_id
if not snap_dir.is_dir():
return False
manifest_path = snap_dir / "manifest.json"
if not manifest_path.exists():
return False
with open(manifest_path) as f:
meta = json.load(f)
restored = 0
for rel in meta.get("files", {}):
src = snap_dir / rel
if not src.exists():
continue
dst = home / rel
dst.parent.mkdir(parents=True, exist_ok=True)
try:
if dst.suffix == ".db":
# Atomic-ish replace for databases
tmp = dst.parent / f".{dst.name}.snap_restore"
shutil.copy2(src, tmp)
dst.unlink(missing_ok=True)
shutil.move(str(tmp), str(dst))
else:
shutil.copy2(src, dst)
restored += 1
except (OSError, PermissionError) as exc:
logger.error("Failed to restore %s: %s", rel, exc)
logger.info("Restored %d files from snapshot %s", restored, snapshot_id)
return restored > 0
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
if not root.exists():
return 0
dirs = sorted(
(d for d in root.iterdir() if d.is_dir()),
key=lambda d: d.name,
reverse=True,
)
deleted = 0
for d in dirs[keep:]:
try:
shutil.rmtree(d)
deleted += 1
except OSError as exc:
logger.warning("Failed to prune snapshot %s: %s", d.name, exc)
return deleted
def prune_quick_snapshots(
keep: int = _QUICK_DEFAULT_KEEP,
hermes_home: Optional[Path] = None,
) -> int:
"""Manually prune quick snapshots. Returns count deleted."""
return _prune_quick_snapshots(_quick_snapshot_root(hermes_home), keep=keep)
def run_quick_backup(args) -> None:
"""CLI entry point for hermes backup --quick."""
label = getattr(args, "label", None)
snap_id = create_quick_snapshot(label=label)
if snap_id:
print(f"State snapshot created: {snap_id}")
snaps = list_quick_snapshots()
print(f" {len(snaps)} snapshot(s) stored in {display_hermes_home()}/state-snapshots/")
print(f" Restore with: /snapshot restore {snap_id}")
else:
print("No state files found to snapshot.")
-1
View File
@@ -5,7 +5,6 @@ Pure display functions with no HermesCLI state dependency.
import json
import logging
import os
import shutil
import subprocess
import threading
+121 -2
View File
@@ -11,6 +11,7 @@ Usage:
import importlib.util
import logging
import subprocess
import sys
from datetime import datetime
from pathlib import Path
@@ -52,6 +53,99 @@ _OPENCLAW_SCRIPT_INSTALLED = (
# Known OpenClaw directory names (current + legacy)
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
def _detect_openclaw_processes() -> list[str]:
"""Detect running OpenClaw processes and services.
Returns a list of human-readable descriptions of what was found.
An empty list means nothing was detected.
"""
found: list[str] = []
# -- systemd service (Linux) ------------------------------------------
if sys.platform != "win32":
try:
result = subprocess.run(
["systemctl", "--user", "is-active", "openclaw-gateway.service"],
capture_output=True, text=True, timeout=5,
)
if result.stdout.strip() == "active":
found.append("systemd service: openclaw-gateway.service")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# -- process scan ------------------------------------------------------
if sys.platform == "win32":
try:
for exe in ("openclaw.exe", "clawd.exe"):
result = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {exe}"],
capture_output=True, text=True, timeout=5,
)
if exe in result.stdout.lower():
found.append(f"process: {exe}")
# Node.js-hosted OpenClaw — tasklist doesn't show command lines,
# so fall back to PowerShell.
ps_cmd = (
'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | '
'Where-Object { $_.CommandLine -match "openclaw|clawd" } | '
'Select-Object -First 1 ProcessId'
)
result = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True, text=True, timeout=5,
)
if result.stdout.strip():
found.append(f"node.exe process with openclaw in command line (PID {result.stdout.strip()})")
except Exception:
pass
else:
try:
result = subprocess.run(
["pgrep", "-f", "openclaw"],
capture_output=True, text=True, timeout=3,
)
if result.returncode == 0:
pids = result.stdout.strip().split()
found.append(f"openclaw process(es) (PIDs: {', '.join(pids)})")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return found
def _warn_if_openclaw_running(auto_yes: bool) -> None:
"""Warn if OpenClaw is still running before migration.
Telegram, Discord, and Slack only allow one active connection per bot
token. Migrating while OpenClaw is running causes both to fight for the
same token.
"""
running = _detect_openclaw_processes()
if not running:
return
print()
print_error("OpenClaw appears to be running:")
for detail in running:
print_info(f" * {detail}")
print_info(
"Messaging platforms (Telegram, Discord, Slack) only allow one "
"active session per bot token. If you continue, both OpenClaw and "
"Hermes may try to use the same token, causing disconnects."
)
print_info("Recommendation: stop OpenClaw before migrating.")
print()
if auto_yes:
return
if not sys.stdin.isatty():
print_info("Non-interactive session — continuing to preview only.")
return
if not prompt_yes_no("Continue anyway?", default=False):
print_info("Migration cancelled. Stop OpenClaw and try again.")
sys.exit(0)
def _warn_if_gateway_running(auto_yes: bool) -> None:
"""Check if a Hermes gateway is running with connected platforms.
@@ -287,8 +381,11 @@ def _cmd_migrate(args):
print_info(f"Workspace: {workspace_target}")
print()
# Check if a gateway is running with connected platforms — migrating tokens
# while the gateway is active will cause conflicts (e.g. Telegram 409).
# Check if OpenClaw is still running — migrating tokens while both are
# active will cause conflicts (e.g. Telegram 409).
_warn_if_openclaw_running(auto_yes)
# Check if a Hermes gateway is running with connected platforms.
_warn_if_gateway_running(auto_yes)
# Ensure config.yaml exists before migration tries to read it
@@ -430,6 +527,28 @@ def _cmd_cleanup(args):
print_success("No OpenClaw directories found. Nothing to clean up.")
return
# Warn if OpenClaw is still running — archiving while the service is
# active causes it to recreate an empty skeleton directory (#8502).
running = _detect_openclaw_processes()
if running:
print()
print_error("OpenClaw appears to be still running:")
for detail in running:
print_info(f" * {detail}")
print_info(
"Archiving .openclaw/ while the service is active may cause it to "
"immediately recreate an empty skeleton directory, destroying your config."
)
print_info("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service")
print()
if not auto_yes:
if not sys.stdin.isatty():
print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.")
return
if not prompt_yes_no("Proceed anyway?", default=False):
print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup")
return
total_archived = 0
for source_dir in dirs_to_check:
-1
View File
@@ -6,7 +6,6 @@ mcp_config.py, and memory_setup.py.
"""
import getpass
import sys
from hermes_cli.colors import Colors, color
+4 -46
View File
@@ -73,6 +73,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[focus topic]"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
aliases=("snap",), args_hint="[create|restore <id>|prune]"),
CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("approve", "Approve a pending dangerous command", "Session",
gateway_only=True, args_hint="[session|always]"),
@@ -129,6 +131,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
@@ -154,6 +157,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True, args_hint="<path>"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
gateway_only=True),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
# Exit
CommandDef("quit", "Exit the CLI", "Exit",
@@ -186,52 +190,6 @@ def resolve_command(name: str) -> CommandDef | None:
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def rebuild_lookups() -> None:
"""Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
Called after plugin commands are registered so they appear in help,
autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
"""
global GATEWAY_KNOWN_COMMANDS
_COMMAND_LOOKUP.clear()
_COMMAND_LOOKUP.update(_build_command_lookup())
COMMANDS.clear()
for cmd in COMMAND_REGISTRY:
if not cmd.gateway_only:
COMMANDS[f"/{cmd.name}"] = _build_description(cmd)
for alias in cmd.aliases:
COMMANDS[f"/{alias}"] = f"{cmd.description} (alias for /{cmd.name})"
COMMANDS_BY_CATEGORY.clear()
for cmd in COMMAND_REGISTRY:
if not cmd.gateway_only:
cat = COMMANDS_BY_CATEGORY.setdefault(cmd.category, {})
cat[f"/{cmd.name}"] = COMMANDS[f"/{cmd.name}"]
for alias in cmd.aliases:
cat[f"/{alias}"] = COMMANDS[f"/{alias}"]
SUBCOMMANDS.clear()
for cmd in COMMAND_REGISTRY:
if cmd.subcommands:
SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
for cmd in COMMAND_REGISTRY:
key = f"/{cmd.name}"
if key in SUBCOMMANDS or not cmd.args_hint:
continue
m = _PIPE_SUBS_RE.search(cmd.args_hint)
if m:
SUBCOMMANDS[key] = m.group(0).split("|")
GATEWAY_KNOWN_COMMANDS = frozenset(
name
for cmd in COMMAND_REGISTRY
if not cmd.cli_only or cmd.gateway_config_gate
for name in (cmd.name, *cmd.aliases)
)
def _build_description(cmd: CommandDef) -> str:
"""Build a CLI-facing description string including usage hint."""
if cmd.args_hint:
+246 -17
View File
@@ -337,6 +337,10 @@ DEFAULT_CONFIG = {
# threshold before escalating to a full timeout. The warning fires
# once per run and does not interrupt the agent. 0 = disable warning.
"gateway_timeout_warning": 900,
# Periodic "still working" notification interval (seconds).
# Sends a status message every N seconds so the user knows the
# agent hasn't died during long tasks. 0 = disable notifications.
"gateway_notify_interval": 600,
},
"terminal": {
@@ -410,9 +414,7 @@ DEFAULT_CONFIG = {
"threshold": 0.50, # compress when context usage exceeds this ratio
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
"protect_last_n": 20, # minimum recent messages to keep uncompressed
"summary_model": "", # empty = use main configured model
"summary_provider": "auto",
"summary_base_url": None,
},
"smart_model_routing": {
"enabled": False,
@@ -698,7 +700,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 16,
"_config_version": 17,
}
# =============================================================================
@@ -814,6 +816,30 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"KIMI_CN_API_KEY": {
"description": "Kimi / Moonshot China API key",
"prompt": "Kimi (China) API key",
"url": "https://platform.moonshot.cn/",
"password": True,
"category": "provider",
"advanced": True,
},
"ARCEEAI_API_KEY": {
"description": "Arcee AI API key",
"prompt": "Arcee AI API key",
"url": "https://chat.arcee.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"ARCEE_BASE_URL": {
"description": "Arcee AI base URL override",
"prompt": "Arcee base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"MINIMAX_API_KEY": {
"description": "MiniMax API key (international)",
"prompt": "MiniMax API key",
@@ -1166,7 +1192,7 @@ OPTIONAL_ENV_VARS = {
"SLACK_BOT_TOKEN": {
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, users:read, files:write",
"im:history, im:read, im:write, users:read, files:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
"url": "https://api.slack.com/apps",
"password": True,
@@ -1542,6 +1568,137 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
return missing
def _normalize_custom_provider_entry(
entry: Any,
*,
provider_key: str = "",
) -> Optional[Dict[str, Any]]:
"""Return a runtime-compatible custom provider entry or ``None``."""
if not isinstance(entry, dict):
return None
base_url = ""
for url_key in ("api", "url", "base_url"):
raw_url = entry.get(url_key)
if isinstance(raw_url, str) and raw_url.strip():
base_url = raw_url.strip()
break
if not base_url:
return None
name = ""
raw_name = entry.get("name")
if isinstance(raw_name, str) and raw_name.strip():
name = raw_name.strip()
elif provider_key.strip():
name = provider_key.strip()
if not name:
return None
normalized: Dict[str, Any] = {
"name": name,
"base_url": base_url,
}
provider_key = provider_key.strip()
if provider_key:
normalized["provider_key"] = provider_key
api_key = entry.get("api_key")
if isinstance(api_key, str) and api_key.strip():
normalized["api_key"] = api_key.strip()
key_env = entry.get("key_env")
if isinstance(key_env, str) and key_env.strip():
normalized["key_env"] = key_env.strip()
api_mode = entry.get("api_mode") or entry.get("transport")
if isinstance(api_mode, str) and api_mode.strip():
normalized["api_mode"] = api_mode.strip()
model_name = entry.get("model") or entry.get("default_model")
if isinstance(model_name, str) and model_name.strip():
normalized["model"] = model_name.strip()
models = entry.get("models")
if isinstance(models, dict) and models:
normalized["models"] = models
context_length = entry.get("context_length")
if isinstance(context_length, int) and context_length > 0:
normalized["context_length"] = context_length
rate_limit_delay = entry.get("rate_limit_delay")
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
normalized["rate_limit_delay"] = rate_limit_delay
return normalized
def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:
"""Normalize ``providers`` config entries into the legacy custom-provider shape."""
if not isinstance(providers_dict, dict):
return []
custom_providers: List[Dict[str, Any]] = []
for key, entry in providers_dict.items():
normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
if normalized is not None:
custom_providers.append(normalized)
return custom_providers
def get_compatible_custom_providers(
config: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Return a deduplicated custom-provider view across legacy and v12+ config.
``custom_providers`` remains the on-disk legacy format, while ``providers``
is the newer keyed schema. Runtime and picker flows still need a single
list-shaped view, but we should not materialise that compatibility layer
back into config.yaml because it duplicates entries in UIs.
"""
if config is None:
config = load_config()
compatible: List[Dict[str, Any]] = []
seen_provider_keys: set = set()
seen_name_url_pairs: set = set()
def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
if entry is None:
return
provider_key = str(entry.get("provider_key", "") or "").strip().lower()
name = str(entry.get("name", "") or "").strip().lower()
base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
model = str(entry.get("model", "") or "").strip().lower()
pair = (name, base_url, model)
if provider_key and provider_key in seen_provider_keys:
return
if name and base_url and pair in seen_name_url_pairs:
return
compatible.append(entry)
if provider_key:
seen_provider_keys.add(provider_key)
if name and base_url:
seen_name_url_pairs.add(pair)
custom_providers = config.get("custom_providers")
if custom_providers is not None:
if not isinstance(custom_providers, list):
return []
for entry in custom_providers:
_append_if_new(_normalize_custom_provider_entry(entry))
for entry in providers_dict_to_custom_providers(config.get("providers")):
_append_if_new(entry)
return compatible
def check_config_version() -> Tuple[int, int]:
"""
Check config version.
@@ -1859,8 +2016,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if migrated_count > 0:
config["providers"] = providers_dict
# Remove the old list
del config["custom_providers"]
# Remove the old list — runtime reads via get_compatible_custom_providers()
config.pop("custom_providers", None)
save_config(config)
if not quiet:
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
@@ -1971,6 +2128,43 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
# ── Version 16 → 17: remove legacy compression.summary_* keys ──
if current_ver < 17:
config = read_raw_config()
comp = config.get("compression", {})
if isinstance(comp, dict):
s_model = comp.pop("summary_model", None)
s_provider = comp.pop("summary_provider", None)
s_base_url = comp.pop("summary_base_url", None)
migrated_keys = []
# Migrate non-empty, non-default values to auxiliary.compression
if s_model and str(s_model).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("model"):
aux_comp["model"] = str(s_model).strip()
migrated_keys.append(f"model={s_model}")
if s_provider and str(s_provider).strip() not in ("", "auto"):
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
aux_comp["provider"] = str(s_provider).strip()
migrated_keys.append(f"provider={s_provider}")
if s_base_url and str(s_base_url).strip():
aux = config.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
if not aux_comp.get("base_url"):
aux_comp["base_url"] = str(s_base_url).strip()
migrated_keys.append(f"base_url={s_base_url}")
if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
config["compression"] = comp
save_config(config)
if not quiet:
if migrated_keys:
print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
else:
print(" ✓ Removed unused compression.summary_* keys")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -2283,6 +2477,7 @@ _FALLBACK_COMMENT = """
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
@@ -2326,6 +2521,7 @@ _COMMENTED_SECTIONS = """
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
@@ -2380,7 +2576,13 @@ def save_config(config: Dict[str, Any]):
def load_env() -> Dict[str, str]:
"""Load environment variables from ~/.hermes/.env."""
"""Load environment variables from ~/.hermes/.env.
Sanitizes lines before parsing so that corrupted files (e.g.
concatenated KEY=VALUE pairs on a single line) are handled
gracefully instead of producing mangled values such as duplicated
bot tokens. See #8908.
"""
env_path = get_env_path()
env_vars = {}
@@ -2389,17 +2591,21 @@ def load_env() -> Dict[str, str]:
# fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.
open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
with open(env_path, **open_kw) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
env_vars[key.strip()] = value.strip().strip('"\'')
raw_lines = f.readlines()
# Sanitize before parsing: split concatenated lines & drop stale
# placeholders so corrupted .env files don't produce invalid tokens.
lines = _sanitize_env_lines(raw_lines)
for line in lines:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
env_vars[key.strip()] = value.strip().strip('"\'')
return env_vars
def _sanitize_env_lines(lines: list) -> list:
"""Fix corrupted .env lines before writing.
"""Fix corrupted .env lines before reading or writing.
Handles two known corruption patterns:
1. Concatenated KEY=VALUE pairs on a single line (missing newline between
@@ -2632,6 +2838,28 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
def reload_env() -> int:
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
Adds/updates vars that changed and removes vars that were deleted from
the .env file (but only vars known to Hermes OPTIONAL_ENV_VARS and
_EXTRA_ENV_KEYS to avoid clobbering unrelated environment).
"""
env_vars = load_env()
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
# Remove known Hermes vars that are no longer in .env
for key in known_keys:
if key not in env_vars and key in os.environ:
del os.environ[key]
count += 1
return count
def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment."""
# Check environment first
@@ -2754,10 +2982,11 @@ def show_config():
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
_sm = compression.get('summary_model', '') or '(main model)'
_aux_comp = config.get('auxiliary', {}).get('compression', {})
_sm = _aux_comp.get('model', '') or '(auto)'
print(f" Model: {_sm}")
comp_provider = compression.get('summary_provider', 'auto')
if comp_provider != 'auto':
comp_provider = _aux_comp.get('provider', 'auto')
if comp_provider and comp_provider != 'auto':
print(f" Provider: {comp_provider}")
# Auxiliary models
+18 -2
View File
@@ -117,14 +117,30 @@ def _gh_cli_candidates() -> list[str]:
def _try_gh_cli_token() -> Optional[str]:
"""Return a token from ``gh auth token`` when the GitHub CLI is available."""
"""Return a token from ``gh auth token`` when the GitHub CLI is available.
When COPILOT_GH_HOST is set, passes ``--hostname`` so gh returns the
correct host's token. Also strips GITHUB_TOKEN / GH_TOKEN from the
subprocess environment so ``gh`` reads from its own credential store
(hosts.yml) instead of just echoing the env var back.
"""
hostname = os.getenv("COPILOT_GH_HOST", "").strip()
# Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN
clean_env = {k: v for k, v in os.environ.items()
if k not in ("GITHUB_TOKEN", "GH_TOKEN")}
for gh_path in _gh_cli_candidates():
cmd = [gh_path, "auth", "token"]
if hostname:
cmd += ["--hostname", hostname]
try:
result = subprocess.run(
[gh_path, "auth", "token"],
cmd,
capture_output=True,
text=True,
timeout=5,
env=clean_env,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
+336
View File
@@ -0,0 +1,336 @@
"""``hermes debug`` — debug tools for Hermes Agent.
Currently supports:
hermes debug share Upload debug report (system info + logs) to a
paste service and print a shareable URL.
"""
import io
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home
# ---------------------------------------------------------------------------
# Paste services — try paste.rs first, dpaste.com as fallback.
# ---------------------------------------------------------------------------
_PASTE_RS_URL = "https://paste.rs/"
_DPASTE_COM_URL = "https://dpaste.com/api/"
# Maximum bytes to read from a single log file for upload.
# paste.rs caps at ~1 MB; we stay under that with headroom.
_MAX_LOG_BYTES = 512_000
def _upload_paste_rs(content: str) -> str:
"""Upload to paste.rs. Returns the paste URL.
paste.rs accepts a plain POST body and returns the URL directly.
"""
data = content.encode("utf-8")
req = urllib.request.Request(
_PASTE_RS_URL, data=data, method="POST",
headers={
"Content-Type": "text/plain; charset=utf-8",
"User-Agent": "hermes-agent/debug-share",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
url = resp.read().decode("utf-8").strip()
if not url.startswith("http"):
raise ValueError(f"Unexpected response from paste.rs: {url[:200]}")
return url
def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str:
"""Upload to dpaste.com. Returns the paste URL.
dpaste.com uses multipart form data.
"""
boundary = "----HermesDebugBoundary9f3c"
def _field(name: str, value: str) -> str:
return (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n'
f"\r\n"
f"{value}\r\n"
)
body = (
_field("content", content)
+ _field("syntax", "text")
+ _field("expiry_days", str(expiry_days))
+ f"--{boundary}--\r\n"
).encode("utf-8")
req = urllib.request.Request(
_DPASTE_COM_URL, data=body, method="POST",
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": "hermes-agent/debug-share",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
url = resp.read().decode("utf-8").strip()
if not url.startswith("http"):
raise ValueError(f"Unexpected response from dpaste.com: {url[:200]}")
return url
def upload_to_pastebin(content: str, expiry_days: int = 7) -> str:
"""Upload *content* to a paste service, trying paste.rs then dpaste.com.
Returns the paste URL on success, raises on total failure.
"""
errors: list[str] = []
# Try paste.rs first (simple, fast)
try:
return _upload_paste_rs(content)
except Exception as exc:
errors.append(f"paste.rs: {exc}")
# Fallback: dpaste.com (supports expiry)
try:
return _upload_dpaste_com(content, expiry_days=expiry_days)
except Exception as exc:
errors.append(f"dpaste.com: {exc}")
raise RuntimeError(
"Failed to upload to any paste service:\n " + "\n ".join(errors)
)
# ---------------------------------------------------------------------------
# Log file reading
# ---------------------------------------------------------------------------
def _resolve_log_path(log_name: str) -> Optional[Path]:
"""Find the log file for *log_name*, falling back to the .1 rotation.
Returns the path if found, or None.
"""
from hermes_cli.logs import LOG_FILES
filename = LOG_FILES.get(log_name)
if not filename:
return None
log_dir = get_hermes_home() / "logs"
primary = log_dir / filename
if primary.exists() and primary.stat().st_size > 0:
return primary
# Fall back to the most recent rotated file (.1).
rotated = log_dir / f"{filename}.1"
if rotated.exists() and rotated.stat().st_size > 0:
return rotated
return None
def _read_log_tail(log_name: str, num_lines: int) -> str:
"""Read the last *num_lines* from a log file, or return a placeholder."""
from hermes_cli.logs import _read_last_n_lines
log_path = _resolve_log_path(log_name)
if log_path is None:
return "(file not found)"
try:
lines = _read_last_n_lines(log_path, num_lines)
return "".join(lines).rstrip("\n")
except Exception as exc:
return f"(error reading: {exc})"
def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]:
"""Read a log file for standalone upload.
Returns the file content (last *max_bytes* if truncated), or None if the
file doesn't exist or is empty.
"""
log_path = _resolve_log_path(log_name)
if log_path is None:
return None
try:
size = log_path.stat().st_size
if size == 0:
return None
if size <= max_bytes:
return log_path.read_text(encoding="utf-8", errors="replace")
# File is larger than max_bytes — read the tail.
with open(log_path, "rb") as f:
f.seek(size - max_bytes)
# Skip partial line at the seek point.
f.readline()
content = f.read().decode("utf-8", errors="replace")
return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}"
except Exception:
return None
# ---------------------------------------------------------------------------
# Debug report collection
# ---------------------------------------------------------------------------
def _capture_dump() -> str:
"""Run ``hermes dump`` and return its stdout as a string."""
from hermes_cli.dump import run_dump
class _FakeArgs:
show_keys = False
old_stdout = sys.stdout
sys.stdout = capture = io.StringIO()
try:
run_dump(_FakeArgs())
except SystemExit:
pass
finally:
sys.stdout = old_stdout
return capture.getvalue()
def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str:
"""Build the summary debug report: system dump + log tails.
Parameters
----------
log_lines
Number of recent lines to include per log file.
dump_text
Pre-captured dump output. If empty, ``hermes dump`` is run
internally.
Returns the report as a plain-text string ready for upload.
"""
buf = io.StringIO()
if not dump_text:
dump_text = _capture_dump()
buf.write(dump_text)
# ── Recent log tails (summary only) ──────────────────────────────────
buf.write("\n\n")
buf.write(f"--- agent.log (last {log_lines} lines) ---\n")
buf.write(_read_log_tail("agent", log_lines))
buf.write("\n\n")
errors_lines = min(log_lines, 100)
buf.write(f"--- errors.log (last {errors_lines} lines) ---\n")
buf.write(_read_log_tail("errors", errors_lines))
buf.write("\n\n")
buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n")
buf.write(_read_log_tail("gateway", errors_lines))
buf.write("\n")
return buf.getvalue()
# ---------------------------------------------------------------------------
# CLI entry points
# ---------------------------------------------------------------------------
def run_debug_share(args):
"""Collect debug report + full logs, upload each, print URLs."""
log_lines = getattr(args, "lines", 200)
expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False)
print("Collecting debug report...")
# Capture dump once — prepended to every paste for context.
dump_text = _capture_dump()
report = collect_debug_report(log_lines=log_lines, dump_text=dump_text)
agent_log = _read_full_log("agent")
gateway_log = _read_full_log("gateway")
# Prepend dump header to each full log so every paste is self-contained.
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if local_only:
print(report)
if agent_log:
print(f"\n\n{'=' * 60}")
print("FULL agent.log")
print(f"{'=' * 60}\n")
print(agent_log)
if gateway_log:
print(f"\n\n{'=' * 60}")
print("FULL gateway.log")
print(f"{'=' * 60}\n")
print(gateway_log)
return
print("Uploading...")
urls: dict[str, str] = {}
failures: list[str] = []
# 1. Summary report (required)
try:
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
except RuntimeError as exc:
print(f"\nUpload failed: {exc}", file=sys.stderr)
print("\nFull report printed below — copy-paste it manually:\n")
print(report)
sys.exit(1)
# 2. Full agent.log (optional)
if agent_log:
try:
urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry)
except Exception as exc:
failures.append(f"agent.log: {exc}")
# 3. Full gateway.log (optional)
if gateway_log:
try:
urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry)
except Exception as exc:
failures.append(f"gateway.log: {exc}")
# Print results
label_width = max(len(k) for k in urls)
print(f"\nDebug report uploaded:")
for label, url in urls.items():
print(f" {label:<{label_width}} {url}")
if failures:
print(f"\n (failed to upload: {', '.join(failures)})")
print(f"\nShare these links with the Hermes team for support.")
def run_debug(args):
"""Route debug subcommands."""
subcmd = getattr(args, "debug_command", None)
if subcmd == "share":
run_debug_share(args)
else:
# Default: show help
print("Usage: hermes debug share [--lines N] [--expire N] [--local]")
print()
print("Commands:")
print(" share Upload debug report to a paste service and print URL")
print()
print("Options:")
print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading")
+2
View File
@@ -721,6 +721,8 @@ def run_doctor(args):
_apikey_providers = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
+49
View File
@@ -15,6 +15,51 @@ def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
def _sanitize_env_file_if_needed(path: Path) -> None:
"""Pre-sanitize a .env file before python-dotenv reads it.
python-dotenv does not handle corrupted lines where multiple
KEY=VALUE pairs are concatenated on a single line (missing newline).
This produces mangled values e.g. a bot token duplicated 8×
(see #8908).
We delegate to ``hermes_cli.config._sanitize_env_lines`` which
already knows all valid Hermes env-var names and can split
concatenated lines correctly.
"""
if not path.exists():
return
try:
from hermes_cli.config import _sanitize_env_lines
except ImportError:
return # early bootstrap — config module not available yet
read_kw = {"encoding": "utf-8", "errors": "replace"}
try:
with open(path, **read_kw) as f:
original = f.readlines()
sanitized = _sanitize_env_lines(original)
if sanitized != original:
import tempfile
fd, tmp = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".env_"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.writelines(sanitized)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception:
pass # best-effort — don't block gateway startup
def load_hermes_dotenv(
*,
hermes_home: str | os.PathLike | None = None,
@@ -34,6 +79,10 @@ def load_hermes_dotenv(
user_env = home_path / ".env"
project_env_path = Path(project_env) if project_env else None
# Fix corrupted .env files before python-dotenv parses them (#8908).
if user_env.exists():
_sanitize_env_file_if_needed(user_env)
if user_env.exists():
_load_dotenv_with_fallback(user_env, override=True)
loaded.append(user_env)
+187 -11
View File
@@ -768,14 +768,22 @@ def _remap_path_for_user(path: str, target_home_dir: str) -> str:
/root/.hermes/hermes-agent -> /home/alice/.hermes/hermes-agent
/opt/hermes -> /opt/hermes (kept as-is)
Note: this function intentionally does NOT resolve symlinks. A venv's
``bin/python`` is typically a symlink to the base interpreter (e.g. a
uv-managed CPython at ``~/.local/share/uv/python/.../python3.11``);
resolving that symlink swaps the unit's ``ExecStart`` to a bare Python
that has none of the venv's site-packages, so the service crashes on
the first ``import``. Keep the symlinked path so the venv activates
its own environment. Lexical expansion only via ``expanduser``.
"""
current_home = Path.home().resolve()
resolved = Path(path).resolve()
current_home = Path.home()
p = Path(path).expanduser()
try:
relative = resolved.relative_to(current_home)
relative = p.relative_to(current_home)
return str(Path(target_home_dir) / relative)
except ValueError:
return str(resolved)
return str(p)
def _hermes_home_for_target_user(target_home_dir: str) -> str:
@@ -1626,7 +1634,7 @@ _PLATFORMS = [
" Create an App-Level Token with scope: connections:write → copy xapp-... token",
"3. Add Bot Token Scopes: Features → OAuth & Permissions → Scopes",
" Required: chat:write, app_mentions:read, channels:history, channels:read,",
" groups:history, im:history, im:read, im:write, users:read, files:write",
" groups:history, im:history, im:read, im:write, users:read, files:read, files:write",
"4. Subscribe to Events: Features → Event Subscriptions → Enable",
" Required events: message.im, message.channels, app_mention",
" Optional: message.groups (for private channels)",
@@ -2119,12 +2127,6 @@ def _setup_dingtalk():
_setup_standard_platform(dingtalk_platform)
def _setup_feishu():
"""Configure Feishu / Lark via the standard platform setup."""
feishu_platform = next(p for p in _PLATFORMS if p["key"] == "feishu")
_setup_standard_platform(feishu_platform)
def _setup_wecom():
"""Configure WeCom (Enterprise WeChat) via the standard platform setup."""
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom")
@@ -2309,6 +2311,178 @@ def _setup_weixin():
print_info(f" User ID: {user_id}")
def _setup_feishu():
"""Interactive setup for Feishu / Lark — scan-to-create or manual credentials."""
print()
print(color(" ─── 🪽 Feishu / Lark Setup ───", Colors.CYAN))
existing_app_id = get_env_value("FEISHU_APP_ID")
existing_secret = get_env_value("FEISHU_APP_SECRET")
if existing_app_id and existing_secret:
print()
print_success("Feishu / Lark is already configured.")
if not prompt_yes_no(" Reconfigure Feishu / Lark?", False):
return
# ── Choose setup method ──
print()
method_choices = [
"Scan QR code to create a new bot automatically (recommended)",
"Enter existing App ID and App Secret manually",
]
method_idx = prompt_choice(" How would you like to set up Feishu / Lark?", method_choices, 0)
credentials = None
used_qr = False
if method_idx == 0:
# ── QR scan-to-create ──
try:
from gateway.platforms.feishu import qr_register
except Exception as exc:
print_error(f" Feishu / Lark onboard import failed: {exc}")
qr_register = None
if qr_register is not None:
try:
credentials = qr_register()
except KeyboardInterrupt:
print()
print_warning(" Feishu / Lark setup cancelled.")
return
except Exception as exc:
print_warning(f" QR registration failed: {exc}")
if credentials:
used_qr = True
if not credentials:
print_info(" QR setup did not complete. Continuing with manual input.")
# ── Manual credential input ──
if not credentials:
print()
print_info(" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)")
print_info(" Create an app, enable the Bot capability, and copy the credentials.")
print()
app_id = prompt(" App ID", password=False)
if not app_id:
print_warning(" Skipped — Feishu / Lark won't work without an App ID.")
return
app_secret = prompt(" App Secret", password=True)
if not app_secret:
print_warning(" Skipped — Feishu / Lark won't work without an App Secret.")
return
domain_choices = ["feishu (China)", "lark (International)"]
domain_idx = prompt_choice(" Domain", domain_choices, 0)
domain = "lark" if domain_idx == 1 else "feishu"
# Try to probe the bot with manual credentials
bot_name = None
try:
from gateway.platforms.feishu import probe_bot
bot_info = probe_bot(app_id, app_secret, domain)
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
else:
print_warning(" Could not verify bot connection. Credentials saved anyway.")
except Exception as exc:
print_warning(f" Credential verification skipped: {exc}")
credentials = {
"app_id": app_id,
"app_secret": app_secret,
"domain": domain,
"open_id": None,
"bot_name": bot_name,
}
# ── Save core credentials ──
app_id = credentials["app_id"]
app_secret = credentials["app_secret"]
domain = credentials.get("domain", "feishu")
open_id = credentials.get("open_id")
bot_name = credentials.get("bot_name")
save_env_value("FEISHU_APP_ID", app_id)
save_env_value("FEISHU_APP_SECRET", app_secret)
save_env_value("FEISHU_DOMAIN", domain)
# Bot identity is resolved at runtime via _hydrate_bot_identity().
# ── Connection mode ──
if used_qr:
connection_mode = "websocket"
else:
print()
mode_choices = [
"WebSocket (recommended — no public URL needed)",
"Webhook (requires a reachable HTTP endpoint)",
]
mode_idx = prompt_choice(" Connection mode", mode_choices, 0)
connection_mode = "webhook" if mode_idx == 1 else "websocket"
if connection_mode == "webhook":
print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook")
print_info(" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH")
print_info(" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN")
save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
if bot_name:
print()
print_success(f" Bot created: {bot_name}")
# ── DM security policy ──
print()
access_choices = [
"Use DM pairing approval (recommended)",
"Allow all direct messages",
"Only allow listed user IDs",
]
access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0)
if access_idx == 0:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_success(" DM pairing enabled.")
print_info(" Unknown users can request access; approve with `hermes pairing approve`.")
elif access_idx == 1:
save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_warning(" Open DM access enabled for Feishu / Lark.")
else:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
default_allow = open_id or ""
allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "")
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
print_success(" Allowlist saved.")
# ── Group policy ──
print()
group_choices = [
"Respond only when @mentioned in groups (recommended)",
"Disable group chats",
]
group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0)
if group_idx == 0:
save_env_value("FEISHU_GROUP_POLICY", "open")
print_info(" Group chats enabled (bot must be @mentioned).")
else:
save_env_value("FEISHU_GROUP_POLICY", "disabled")
print_info(" Group chats disabled.")
# ── Home channel ──
print()
home_channel = prompt(" Home chat ID (optional, for cron/notifications)", password=False)
if home_channel:
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
print_success(f" Home channel set to {home_channel}")
print()
print_success("🪽 Feishu / Lark configured!")
print_info(f" App ID: {app_id}")
print_info(f" Domain: {domain}")
if bot_name:
print_info(f" Bot: {bot_name}")
def _setup_signal():
"""Interactive setup for Signal messenger."""
import shutil
@@ -2486,6 +2660,8 @@ def gateway_setup():
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
elif platform["key"] == "feishu":
_setup_feishu()
else:
_setup_standard_platform(platform)
+207 -104
View File
@@ -999,7 +999,7 @@ def select_provider_and_model(args=None):
from hermes_cli.auth import (
resolve_provider, AuthError, format_auth_error,
)
from hermes_cli.config import load_config, get_env_value
from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value
config = load_config()
current_model = config.get("model")
@@ -1034,28 +1034,9 @@ def select_provider_and_model(args=None):
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
active = "custom"
provider_labels = {
"openrouter": "OpenRouter",
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
"qwen-oauth": "Qwen OAuth",
"copilot-acp": "GitHub Copilot ACP",
"copilot": "GitHub Copilot",
"anthropic": "Anthropic",
"gemini": "Google AI Studio",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"ai-gateway": "AI Gateway",
"kilocode": "Kilo Code",
"alibaba": "Alibaba Cloud (DashScope)",
"huggingface": "Hugging Face",
"xiaomi": "Xiaomi MiMo",
"custom": "Custom endpoint",
}
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
active_label = provider_labels.get(active, active) if active else "none"
print()
@@ -1063,38 +1044,12 @@ def select_provider_and_model(args=None):
print(f" Active provider: {active_label}")
print()
# Step 1: Provider selection — top providers shown first, rest behind "More..."
top_providers = [
("nous", "Nous Portal (Nous Research subscription)"),
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
("openai-codex", "OpenAI Codex"),
("qwen-oauth", "Qwen OAuth (reuses local Qwen CLI login)"),
("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
("huggingface", "Hugging Face Inference Providers (20+ open models)"),
]
extended_providers = [
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
("gemini", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
("minimax", "MiniMax (global direct API)"),
("minimax-cn", "MiniMax China (domestic direct API)"),
("kilocode", "Kilo Code (Kilo Gateway API)"),
("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
("opencode-go", "OpenCode Go (open models, $10/month subscription)"),
("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
("xiaomi", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
]
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
custom_providers_cfg = cfg.get("custom_providers") or []
custom_provider_map = {}
if not isinstance(custom_providers_cfg, list):
return custom_provider_map
for entry in custom_providers_cfg:
for entry in get_compatible_custom_providers(cfg):
if not isinstance(entry, dict):
continue
name = (entry.get("name") or "").strip()
@@ -1102,12 +1057,20 @@ def select_provider_and_model(args=None):
if not name or not base_url:
continue
key = "custom:" + name.lower().replace(" ", "-")
provider_key = (entry.get("provider_key") or "").strip()
if provider_key:
try:
resolve_provider(provider_key)
except AuthError:
key = provider_key
custom_provider_map[key] = {
"name": name,
"base_url": base_url,
"api_key": entry.get("api_key", ""),
"key_env": entry.get("key_env", ""),
"model": entry.get("model", ""),
"api_mode": entry.get("api_mode", ""),
"provider_key": provider_key,
}
return custom_provider_map
@@ -1119,29 +1082,22 @@ def select_provider_and_model(args=None):
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
saved_model = provider_info.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
top_keys = {k for k, _ in top_providers}
extended_keys = {k for k, _ in extended_providers}
# If the active provider is in the extended list, promote it into top
if active and active in extended_keys:
promoted = [(k, l) for k, l in extended_providers if k == active]
extended_providers = [(k, l) for k, l in extended_providers if k != active]
top_providers = promoted + top_providers
top_keys.add(active)
# Build the primary menu
# Build the menu
ordered = []
default_idx = 0
for key, label in top_providers:
for key, label in all_providers:
if active and key == active:
ordered.append((key, f"{label} ← currently active"))
default_idx = len(ordered) - 1
else:
ordered.append((key, label))
ordered.append(("more", "More providers..."))
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers"))
if _has_saved_custom_list:
ordered.append(("remove-custom", "Remove a saved custom provider"))
ordered.append(("cancel", "Cancel"))
provider_idx = _prompt_provider_choice(
@@ -1153,22 +1109,6 @@ def select_provider_and_model(args=None):
selected_provider = ordered[provider_idx][0]
# "More providers..." — show the extended list
if selected_provider == "more":
ext_ordered = list(extended_providers)
ext_ordered.append(("custom", "Custom endpoint (enter URL manually)"))
if _custom_provider_map:
ext_ordered.append(("remove-custom", "Remove a saved custom provider"))
ext_ordered.append(("cancel", "Cancel"))
ext_idx = _prompt_provider_choice(
[label for _, label in ext_ordered], default=0,
)
if ext_idx is None or ext_ordered[ext_idx][0] == "cancel":
print("No change.")
return
selected_provider = ext_ordered[ext_idx][0]
# Step 2: Provider-specific setup + model selection
if selected_provider == "openrouter":
_model_flow_openrouter(config, current_model)
@@ -1184,7 +1124,7 @@ def select_provider_and_model(args=None):
_model_flow_copilot(config, current_model)
elif selected_provider == "custom":
_model_flow_custom(config)
elif selected_provider.startswith("custom:"):
elif selected_provider.startswith("custom:") or selected_provider in _custom_provider_map:
provider_info = _named_custom_provider_map(load_config()).get(selected_provider)
if provider_info is None:
print(
@@ -1199,7 +1139,7 @@ def select_provider_and_model(args=None):
_model_flow_anthropic(config, current_model)
elif selected_provider == "kimi-coding":
_model_flow_kimi(config, current_model)
elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"):
elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi", "arcee"):
_model_flow_api_key_provider(config, selected_provider, current_model)
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
@@ -1869,7 +1809,9 @@ def _model_flow_named_custom(config, provider_info):
name = provider_info["name"]
base_url = provider_info["base_url"]
api_key = provider_info.get("api_key", "")
key_env = provider_info.get("key_env", "")
saved_model = provider_info.get("model", "")
provider_key = (provider_info.get("provider_key") or "").strip()
print(f" Provider: {name}")
print(f" URL: {base_url}")
@@ -1952,10 +1894,15 @@ def _model_flow_named_custom(config, provider_info):
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["provider"] = "custom"
model["base_url"] = base_url
if api_key:
model["api_key"] = api_key
if provider_key:
model["provider"] = provider_key
model.pop("base_url", None)
model.pop("api_key", None)
else:
model["provider"] = "custom"
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:
@@ -1965,8 +1912,23 @@ def _model_flow_named_custom(config, provider_info):
save_config(cfg)
deactivate_provider()
# Save model name to the custom_providers entry for next time
_save_custom_provider(base_url, api_key, model_name)
# Persist the selected model back to whichever schema owns this endpoint.
if provider_key:
cfg = load_config()
providers_cfg = cfg.get("providers")
if isinstance(providers_cfg, dict):
provider_entry = providers_cfg.get(provider_key)
if isinstance(provider_entry, dict):
provider_entry["default_model"] = model_name
if api_key and not str(provider_entry.get("api_key", "") or "").strip():
provider_entry["api_key"] = api_key
if key_env and not str(provider_entry.get("key_env", "") or "").strip():
provider_entry["key_env"] = key_env
cfg["providers"] = providers_cfg
save_config(cfg)
else:
# Save model name to the custom_providers entry for next time
_save_custom_provider(base_url, api_key, model_name)
print(f"\n✅ Model set to: {model_name}")
print(f" Provider: {name} ({base_url})")
@@ -2666,13 +2628,12 @@ def _run_anthropic_oauth_flow(save_env_value):
def _model_flow_anthropic(config, current_model=""):
"""Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds."""
import os
from hermes_cli.auth import (
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
_prompt_model_selection, _save_model_choice,
deactivate_provider,
)
from hermes_cli.config import (
get_env_value, save_env_value, load_config, save_config,
save_env_value, load_config, save_config,
save_anthropic_api_key,
)
from hermes_cli.models import _PROVIDER_MODELS
@@ -2834,6 +2795,12 @@ def cmd_dump(args):
run_dump(args)
def cmd_debug(args):
"""Debug tools (share report, etc.)."""
from hermes_cli.debug import run_debug
run_debug(args)
def cmd_config(args):
"""Configuration management."""
from hermes_cli.config import config_command
@@ -2842,8 +2809,12 @@ def cmd_config(args):
def cmd_backup(args):
"""Back up Hermes home directory to a zip file."""
from hermes_cli.backup import run_backup
run_backup(args)
if getattr(args, "quick", False):
from hermes_cli.backup import run_quick_backup
run_quick_backup(args)
else:
from hermes_cli.backup import run_backup
run_backup(args)
def cmd_import(args):
@@ -2970,6 +2941,44 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
return default
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available.
Args:
web_dir: Path to the ``web/`` source directory.
fatal: If True, print error guidance and return False on failure
instead of a soft warning (used by ``hermes web``).
Returns True if the build succeeded or was skipped (no package.json).
"""
if not (web_dir / "package.json").exists():
return True
import shutil
npm = shutil.which("npm")
if not npm:
if fatal:
print("Web UI frontend not built and npm is not available.")
print("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
print("→ Building web UI...")
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
if r1.returncode != 0:
print(f" {'' if fatal else ''} Web UI npm install failed"
+ ("" if fatal else " (hermes web will not be available)"))
if fatal:
print(" Run manually: cd web && npm install && npm run build")
return False
r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True)
if r2.returncode != 0:
print(f" {'' if fatal else ''} Web UI build failed"
+ ("" if fatal else " (hermes web will not be available)"))
if fatal:
print(" Run manually: cd web && npm install && npm run build")
return False
print(" ✓ Web UI built")
return True
def _update_via_zip(args):
"""Update Hermes Agent by downloading a ZIP archive.
@@ -3064,7 +3073,10 @@ def _update_via_zip(args):
check=True,
)
_install_python_dependencies_with_optional_fallback(pip_cmd)
# Build web UI frontend (optional — requires npm)
_build_web_ui(PROJECT_ROOT / "web")
# Sync skills
try:
from tools.skills_sync import sync_skills
@@ -3811,7 +3823,10 @@ def cmd_update(args):
if shutil.which("npm"):
print("→ Updating Node.js dependencies...")
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
# Build web UI frontend (optional — requires npm)
_build_web_ui(PROJECT_ROOT / "web")
print()
print("✓ Code updated!")
@@ -4093,7 +4108,7 @@ def _coalesce_session_name_args(argv: list) -> list:
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth",
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall",
"profile",
"profile", "dashboard",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -4243,18 +4258,24 @@ def cmd_profile(args):
print(f' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(f' export PATH="$HOME/.local/bin:$PATH"')
# Profile dir for display
try:
profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home()))
except ValueError:
profile_dir_display = str(profile_dir)
# Next steps
print(f"\nNext steps:")
print(f" {name} setup Configure API keys and model")
print(f" {name} chat Start chatting")
print(f" {name} gateway start Start the messaging gateway")
if clone or clone_all:
try:
profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home()))
except ValueError:
profile_dir_display = str(profile_dir)
print(f"\n Edit {profile_dir_display}/.env for different API keys")
print(f" Edit {profile_dir_display}/SOUL.md for different personality")
else:
print(f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,")
print(f" or it will inherit keys from your shell environment.")
print(f" Edit {profile_dir_display}/SOUL.md to customize personality")
print()
except (ValueError, FileExistsError, FileNotFoundError) as e:
@@ -4365,6 +4386,27 @@ def cmd_profile(args):
sys.exit(1)
def cmd_dashboard(args):
"""Start the web UI server."""
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
except ImportError:
print("Web UI dependencies not installed.")
print("Install them with: pip install hermes-agent[web]")
sys.exit(1)
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
from hermes_cli.web_server import start_server
start_server(
host=args.host,
port=args.port,
open_browser=not args.no_open,
)
def cmd_completion(args):
"""Print shell completion script."""
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
@@ -4430,6 +4472,7 @@ Examples:
hermes logs -f Follow agent.log in real time
hermes logs errors View errors.log
hermes logs --since 1h Lines from the last hour
hermes debug share Upload debug report for support
hermes update Update to latest version
For more help on a command:
@@ -4516,7 +4559,7 @@ For more help on a command:
)
chat_parser.add_argument(
"--provider",
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "xiaomi"],
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"],
default=None,
help="Inference provider (default: auto)"
)
@@ -4959,6 +5002,43 @@ For more help on a command:
)
dump_parser.set_defaults(func=cmd_dump)
# =========================================================================
# debug command
# =========================================================================
debug_parser = subparsers.add_parser(
"debug",
help="Debug tools — upload logs and system info for support",
description="Debug utilities for Hermes Agent. Use 'hermes debug share' to "
"upload a debug report (system info + recent logs) to a paste "
"service and get a shareable URL.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes debug share Upload debug report and print URL
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
""",
)
debug_sub = debug_parser.add_subparsers(dest="debug_command")
share_parser = debug_sub.add_parser(
"share",
help="Upload debug report to a paste service and print a shareable URL",
)
share_parser.add_argument(
"--lines", type=int, default=200,
help="Number of log lines to include per log file (default: 200)",
)
share_parser.add_argument(
"--expire", type=int, default=7,
help="Paste expiry in days (default: 7)",
)
share_parser.add_argument(
"--local", action="store_true",
help="Print the report locally instead of uploading",
)
debug_parser.set_defaults(func=cmd_debug)
# =========================================================================
# backup command
# =========================================================================
@@ -4966,12 +5046,22 @@ For more help on a command:
"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)"
"skills, sessions, and data (excludes the hermes-agent codebase). "
"Use --quick for a fast snapshot of just critical state files."
)
backup_parser.add_argument(
"-o", "--output",
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)"
)
backup_parser.add_argument(
"-q", "--quick",
action="store_true",
help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)"
)
backup_parser.add_argument(
"-l", "--label",
help="Label for the snapshot (only used with --quick)"
)
backup_parser.set_defaults(func=cmd_backup)
# =========================================================================
@@ -5812,6 +5902,19 @@ For more help on a command:
)
completion_parser.set_defaults(func=cmd_completion)
# =========================================================================
# dashboard command
# =========================================================================
dashboard_parser = subparsers.add_parser(
"dashboard",
help="Start the web UI dashboard",
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
)
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================
# logs command
# =========================================================================
+19 -4
View File
@@ -8,8 +8,9 @@ Different LLM providers expect model identifiers in different formats:
hyphens: ``claude-sonnet-4-6``.
- **Copilot** expects bare names *with* dots preserved:
``claude-sonnet-4.6``.
- **OpenCode Zen** follows the same dot-to-hyphen convention as
Anthropic: ``claude-sonnet-4-6``.
- **OpenCode Zen** preserves dots for GPT/GLM/Gemini/Kimi/MiniMax-style
model IDs, but Claude still uses hyphenated native names like
``claude-sonnet-4-6``.
- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``.
- **DeepSeek** only accepts two model identifiers:
``deepseek-chat`` and ``deepseek-reasoner``.
@@ -50,6 +51,7 @@ _VENDOR_PREFIXES: dict[str, str] = {
"grok": "x-ai",
"qwen": "qwen",
"mimo": "xiaomi",
"trinity": "arcee-ai",
"nemotron": "nvidia",
"llama": "meta-llama",
"step": "stepfun",
@@ -67,7 +69,6 @@ _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
# Providers that want bare names with dots replaced by hyphens.
_DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
"anthropic",
"opencode-zen",
})
# Providers that want bare names with dots preserved.
@@ -88,11 +89,13 @@ _AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
_MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
"zai",
"kimi-coding",
"kimi-coding-cn",
"minimax",
"minimax-cn",
"alibaba",
"qwen-oauth",
"xiaomi",
"arcee",
"custom",
})
@@ -329,6 +332,9 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
>>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen")
'claude-sonnet-4-6'
>>> normalize_model_for_provider("minimax-m2.5-free", "opencode-zen")
'minimax-m2.5-free'
>>> normalize_model_for_provider("deepseek-v3", "deepseek")
'deepseek-chat'
@@ -351,7 +357,16 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
if provider in _AGGREGATOR_PROVIDERS:
return _prepend_vendor(name)
# --- Anthropic / OpenCode: strip matching provider prefix, dots -> hyphens ---
# --- OpenCode Zen: Claude stays hyphenated; other models keep dots ---
if provider == "opencode-zen":
bare = _strip_matching_provider_prefix(name, provider)
if "/" in bare:
return bare
if bare.lower().startswith("claude-"):
return _dots_to_hyphens(bare)
return bare
# --- Anthropic: strip matching provider prefix, dots -> hyphens ---
if provider in _DOT_TO_HYPHEN_PROVIDERS:
bare = _strip_matching_provider_prefix(name, provider)
if "/" in bare:
+120 -13
View File
@@ -21,6 +21,7 @@ OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``).
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import List, NamedTuple, Optional
@@ -40,7 +41,6 @@ from agent.models_dev import (
get_model_capabilities,
get_model_info,
list_provider_models,
search_models_dev,
)
logger = logging.getLogger(__name__)
@@ -57,10 +57,36 @@ _HERMES_MODEL_WARNING = (
"(Claude, GPT, Gemini, DeepSeek, etc.)."
)
# Match only the real Nous Research Hermes 3 / Hermes 4 chat families.
# The previous substring check (`"hermes" in name.lower()`) false-positived on
# unrelated local Modelfiles like ``hermes-brain:qwen3-14b-ctx16k`` that just
# happen to carry "hermes" in their tag but are fully tool-capable.
#
# Positive examples the regex must match:
# NousResearch/Hermes-3-Llama-3.1-70B, hermes-4-405b, openrouter/hermes3:70b
# Negative examples it must NOT match:
# hermes-brain:qwen3-14b-ctx16k, qwen3:14b, claude-opus-4-6
_NOUS_HERMES_NON_AGENTIC_RE = re.compile(
r"(?:^|[/:])hermes[-_ ]?[34](?:[-_.:]|$)",
re.IGNORECASE,
)
def is_nous_hermes_non_agentic(model_name: str) -> bool:
"""Return True if *model_name* is a real Nous Hermes 3/4 chat model.
Used to decide whether to surface the non-agentic warning at startup.
Callers in :mod:`cli.py` and here should go through this single helper
so the two sites don't drift.
"""
if not model_name:
return False
return bool(_NOUS_HERMES_NON_AGENTIC_RE.search(model_name))
def _check_hermes_model_warning(model_name: str) -> str:
"""Return a warning string if *model_name* looks like a Hermes LLM model."""
if "hermes" in model_name.lower():
"""Return a warning string if *model_name* is a Nous Hermes 3/4 chat model."""
if is_nous_hermes_non_agentic(model_name):
return _HERMES_MODEL_WARNING
return ""
@@ -908,6 +934,65 @@ def list_authenticated_providers(
seen_slugs.add(pid)
seen_slugs.add(hermes_slug)
# --- 2b. Cross-check canonical provider list ---
# Catches providers that are in CANONICAL_PROVIDERS but weren't found
# in PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS (keeps /model in sync
# with `hermes model`).
try:
from hermes_cli.models import CANONICAL_PROVIDERS as _canon_provs
except ImportError:
_canon_provs = []
for _cp in _canon_provs:
if _cp.slug in seen_slugs:
continue
# Check credentials via PROVIDER_REGISTRY (auth.py)
_cp_config = _auth_registry.get(_cp.slug)
_cp_has_creds = False
if _cp_config and _cp_config.api_key_env_vars:
_cp_has_creds = any(os.environ.get(ev) for ev in _cp_config.api_key_env_vars)
# Also check auth store and credential pool
if not _cp_has_creds:
try:
from hermes_cli.auth import _load_auth_store
_cp_store = _load_auth_store()
_cp_providers_store = _cp_store.get("providers", {})
_cp_pool_store = _cp_store.get("credential_pool", {})
if _cp_store and (
_cp.slug in _cp_providers_store
or _cp.slug in _cp_pool_store
):
_cp_has_creds = True
except Exception:
pass
if not _cp_has_creds:
try:
from agent.credential_pool import load_pool
_cp_pool = load_pool(_cp.slug)
if _cp_pool.has_credentials():
_cp_has_creds = True
except Exception:
pass
if not _cp_has_creds:
continue
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
_cp_top = _cp_model_ids[:max_models]
results.append({
"slug": _cp.slug,
"name": _cp.label,
"is_current": _cp.slug == current_provider,
"is_user_defined": False,
"models": _cp_top,
"total_models": _cp_total,
"source": "canonical",
})
seen_slugs.add(_cp.slug)
# --- 3. User-defined endpoints from config ---
if user_providers and isinstance(user_providers, dict):
for ep_name, ep_cfg in user_providers.items():
@@ -917,9 +1002,16 @@ def list_authenticated_providers(
api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
default_model = ep_cfg.get("default_model", "")
# Build models list from both default_model and full models array
models_list = []
if default_model:
models_list.append(default_model)
# Also include the full models list from config
cfg_models = ep_cfg.get("models", [])
if isinstance(cfg_models, list):
for m in cfg_models:
if m and m not in models_list:
models_list.append(m)
# Try to probe /v1/models if URL is set (but don't block on it)
# For now just show what we know from config
@@ -935,7 +1027,17 @@ def list_authenticated_providers(
})
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same provider name are grouped into a
# single picker row so that e.g. four Ollama Cloud entries
# (qwen3-coder, glm-5.1, kimi-k2, minimax-m2.7) appear as one
# "Ollama Cloud" row with four models inside instead of four
# duplicate "Ollama Cloud" rows. Entries with distinct provider names
# still produce separate rows (e.g. Ollama Cloud vs Moonshot).
if custom_providers and isinstance(custom_providers, list):
from collections import OrderedDict
groups: "OrderedDict[str, dict]" = OrderedDict()
for entry in custom_providers:
if not isinstance(entry, dict):
continue
@@ -951,23 +1053,28 @@ def list_authenticated_providers(
continue
slug = custom_provider_slug(display_name)
if slug not in groups:
groups[slug] = {
"name": display_name,
"api_url": api_url,
"models": [],
}
default_model = (entry.get("model") or "").strip()
if default_model and default_model not in groups[slug]["models"]:
groups[slug]["models"].append(default_model)
for slug, grp in groups.items():
if slug in seen_slugs:
continue
models_list = []
default_model = (entry.get("model") or "").strip()
if default_model:
models_list.append(default_model)
results.append({
"slug": slug,
"name": display_name,
"name": grp["name"],
"is_current": slug == current_provider,
"is_user_defined": True,
"models": models_list,
"total_models": len(models_list),
"models": grp["models"],
"total_models": len(grp["models"]),
"source": "user-config",
"api_url": api_url,
"api_url": grp["api_url"],
})
seen_slugs.add(slug)
+74 -42
View File
@@ -12,7 +12,7 @@ import os
import urllib.request
import urllib.error
from difflib import get_close_matches
from typing import Any, Optional
from typing import Any, NamedTuple, Optional
COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
@@ -70,13 +70,13 @@ def _codex_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"xiaomi/mimo-v2-pro",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-4.5",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"xiaomi/mimo-v2-pro",
"openai/gpt-5.3-codex",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
@@ -130,6 +130,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemma-4-26b-it",
],
"zai": [
"glm-5.1",
"glm-5",
"glm-5-turbo",
"glm-4.7",
@@ -157,6 +158,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"kimi-coding-cn": [
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"moonshot": [
"kimi-k2.5",
"kimi-k2-thinking",
@@ -193,6 +200,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"mimo-v2-omni",
"mimo-v2-flash",
],
"arcee": [
"trinity-large-thinking",
"trinity-large-preview",
"trinity-mini",
],
"opencode-zen": [
"gpt-5.4-pro",
"gpt-5.4",
@@ -478,29 +490,52 @@ def check_nous_free_tier() -> bool:
return False # default to paid on error — don't block users
_PROVIDER_LABELS = {
"openrouter": "OpenRouter",
"openai-codex": "OpenAI Codex",
"copilot-acp": "GitHub Copilot ACP",
"nous": "Nous Portal",
"copilot": "GitHub Copilot",
"gemini": "Google AI Studio",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"anthropic": "Anthropic",
"deepseek": "DeepSeek",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"ai-gateway": "AI Gateway",
"kilocode": "Kilo Code",
"alibaba": "Alibaba Cloud (DashScope)",
"qwen-oauth": "Qwen OAuth (Portal)",
"huggingface": "Hugging Face",
"xiaomi": "Xiaomi MiMo",
"custom": "Custom endpoint",
}
# ---------------------------------------------------------------------------
# Canonical provider list — single source of truth for provider identity.
# Every code path that lists, displays, or iterates providers derives from
# this list: hermes model, /model, /provider, list_authenticated_providers.
#
# Fields:
# slug — internal provider ID (used in config.yaml, --provider flag)
# label — short display name
# tui_desc — longer description for the `hermes model` interactive picker
# ---------------------------------------------------------------------------
class ProviderEntry(NamedTuple):
slug: str
label: str
tui_desc: str # detailed description for `hermes model` TUI
CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
ProviderEntry("kimi-coding", "Kimi / Moonshot", "Kimi / Moonshot (Moonshot AI direct API)"),
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"),
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
ProviderEntry("ai-gateway", "AI Gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
]
# Derived dicts — used throughout the codebase
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
_PROVIDER_ALIASES = {
"glm": "zai",
@@ -518,6 +553,10 @@ _PROVIDER_ALIASES = {
"google-ai-studio": "gemini",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
"moonshot-cn": "kimi-coding-cn",
"arcee-ai": "arcee",
"arceeai": "arcee",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
@@ -543,6 +582,9 @@ _PROVIDER_ALIASES = {
"huggingface-hub": "huggingface",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
"grok": "xai",
"x-ai": "xai",
"x.ai": "xai",
}
@@ -629,13 +671,6 @@ def model_ids(*, force_refresh: bool = False) -> list[str]:
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
def menu_labels(*, force_refresh: bool = False) -> list[str]:
"""Return display labels like 'anthropic/claude-opus-4.6 (recommended)'."""
labels = []
for mid, desc in fetch_openrouter_models(force_refresh=force_refresh):
labels.append(f"{mid} ({desc})" if desc else mid)
return labels
# ---------------------------------------------------------------------------
@@ -835,23 +870,20 @@ def list_available_providers() -> list[dict[str, str]]:
Each dict has ``id``, ``label``, and ``aliases``.
Checks which providers have valid credentials configured.
Derives the provider list from :data:`CANONICAL_PROVIDERS` (single
source of truth shared with ``hermes model``, ``/model``, etc.).
"""
# Canonical providers in display order
_PROVIDER_ORDER = [
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "huggingface",
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
"qwen-oauth", "xiaomi",
"opencode-zen", "opencode-go",
"ai-gateway", "deepseek", "custom",
]
# Derive display order from canonical list + custom
provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]
# Build reverse alias map
aliases_for: dict[str, list[str]] = {}
for alias, canonical in _PROVIDER_ALIASES.items():
aliases_for.setdefault(canonical, []).append(alias)
result = []
for pid in _PROVIDER_ORDER:
for pid in provider_order:
label = _PROVIDER_LABELS.get(pid, pid)
alias_list = aliases_for.get(pid, [])
# Check if this provider has credentials available
-14
View File
@@ -31,7 +31,6 @@ import importlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
import types
from dataclasses import dataclass, field
@@ -584,19 +583,6 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
def get_plugin_tool_names() -> Set[str]:
"""Return the set of tool names registered by plugins."""
return get_plugin_manager()._plugin_tool_names
def get_plugin_cli_commands() -> Dict[str, dict]:
"""Return CLI commands registered by general plugins.
Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}``
suitable for wiring into argparse subparsers.
"""
return dict(get_plugin_manager()._cli_commands)
def get_plugin_context_engine():
"""Return the plugin-registered context engine, or None."""
+10
View File
@@ -459,6 +459,16 @@ def create_profile(
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# Seed a default SOUL.md so the user has a file to customize immediately.
# Skipped when the profile already has one (from --clone / --clone-all).
soul_path = profile_dir / "SOUL.md"
if not soul_path.exists():
try:
from hermes_cli.default_soul import DEFAULT_SOUL_MD
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
except Exception:
pass # best-effort — don't fail profile creation over this
return profile_dir
+10
View File
@@ -136,6 +136,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
base_url_env_var="XIAOMI_BASE_URL",
),
"arcee": HermesOverlay(
transport="openai_chat",
base_url_override="https://api.arcee.ai/api/v1",
base_url_env_var="ARCEE_BASE_URL",
),
}
@@ -179,6 +184,7 @@ ALIASES: Dict[str, str] = {
# kimi-for-coding (models.dev ID)
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
"moonshot": "kimi-for-coding",
# minimax-cn
@@ -230,6 +236,10 @@ ALIASES: Dict[str, str] = {
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
# arcee
"arcee-ai": "arcee",
"arceeai": "arcee",
# Local server aliases → virtual "local" concept (resolved via user config)
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
+61 -10
View File
@@ -26,7 +26,7 @@ from hermes_cli.auth import (
resolve_external_process_provider_credentials,
has_usable_secret,
)
from hermes_cli.config import load_config
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_constants import OPENROUTER_BASE_URL
@@ -275,14 +275,56 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
return None
config = load_config()
# First check providers: dict (new-style user-defined providers)
providers = config.get("providers")
if isinstance(providers, dict):
for ep_name, entry in providers.items():
if not isinstance(entry, dict):
continue
# Match exact name or normalized name
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
# Found match by provider key
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
return {
"name": entry.get("name", ep_name),
"base_url": base_url.strip(),
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
# Also check the 'name' field if present
display_name = entry.get("name", "")
if display_name:
display_norm = _normalize_custom_provider_name(display_name)
if requested_norm in {display_name, display_norm, f"custom:{display_norm}"}:
# Found match by display name
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
return {
"name": display_name,
"base_url": base_url.strip(),
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
# Fall back to custom_providers: list (legacy format)
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
if isinstance(custom_providers, dict):
logger.warning(
"custom_providers in config.yaml is a dict, not a list. "
"Each entry must be prefixed with '-' in YAML. "
"Run 'hermes doctor' for details."
)
if isinstance(custom_providers, dict):
logger.warning(
"custom_providers in config.yaml is a dict, not a list. "
"Each entry must be prefixed with '-' in YAML. "
"Run 'hermes doctor' for details."
)
return None
custom_providers = get_compatible_custom_providers(config)
if not custom_providers:
return None
for entry in custom_providers:
@@ -294,13 +336,21 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
continue
name_norm = _normalize_custom_provider_name(name)
menu_key = f"custom:{name_norm}"
if requested_norm not in {name_norm, menu_key}:
provider_key = str(entry.get("provider_key", "") or "").strip()
provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else ""
provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else ""
if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}:
continue
result = {
"name": name.strip(),
"base_url": base_url.strip(),
"api_key": str(entry.get("api_key", "") or "").strip(),
}
key_env = str(entry.get("key_env", "") or "").strip()
if key_env:
result["key_env"] = key_env
if provider_key:
result["provider_key"] = provider_key
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
@@ -342,6 +392,7 @@ def _resolve_named_custom_runtime(
api_key_candidates = [
(explicit_api_key or "").strip(),
str(custom_provider.get("api_key", "") or "").strip(),
os.getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
os.getenv("OPENAI_API_KEY", "").strip(),
os.getenv("OPENROUTER_API_KEY", "").strip(),
]
@@ -557,7 +608,7 @@ def _resolve_explicit_runtime(
base_url = explicit_base_url
if not base_url:
if provider == "kimi-coding":
if provider in ("kimi-coding", "kimi-coding-cn"):
creds = resolve_api_key_provider_credentials(provider)
base_url = creds.get("base_url", "").rstrip("/")
else:
+5 -47
View File
@@ -43,14 +43,6 @@ def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
return {}
def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
if not model_name:
return
model_cfg = _model_config_dict(config)
model_cfg["default"] = model_name
config["model"] = model_cfg
def _get_credential_pool_strategies(config: Dict[str, Any]) -> Dict[str, str]:
strategies = config.get("credential_pool_strategies")
return dict(strategies) if isinstance(strategies, dict) else {}
@@ -104,8 +96,10 @@ _DEFAULT_PROVIDER_MODELS = {
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
"gemma-4-31b-it", "gemma-4-26b-it",
],
"zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
@@ -135,43 +129,6 @@ def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
agent_cfg["reasoning_effort"] = effort
def _setup_copilot_reasoning_selection(
config: Dict[str, Any],
model_id: str,
prompt_choice,
*,
catalog: Optional[list[dict[str, Any]]] = None,
api_key: str = "",
) -> None:
from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id
normalized_model = normalize_copilot_model_id(
model_id,
catalog=catalog,
api_key=api_key,
) or model_id
efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key)
if not efforts:
return
current_effort = _current_reasoning_effort(config)
choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"]
if current_effort == "none":
default_idx = len(efforts)
elif current_effort in efforts:
default_idx = efforts.index(current_effort)
elif "medium" in efforts:
default_idx = efforts.index("medium")
else:
default_idx = len(choices) - 1
effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx)
if effort_idx < len(efforts):
_set_reasoning_effort(config, efforts[effort_idx])
elif effort_idx == len(efforts):
_set_reasoning_effort(config, "none")
# Import config helpers
@@ -815,6 +772,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
@@ -1779,7 +1737,7 @@ def _setup_slack():
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
print_info(" Required scopes: chat:write, app_mentions:read,")
print_info(" channels:history, channels:read, im:history,")
print_info(" im:read, im:write, users:read, files:write")
print_info(" im:read, im:write, users:read, files:read, files:write")
print_info(" Optional for private channels: groups:history")
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
print_info(" Required events: message.im, message.channels, app_mention")
+1 -1
View File
@@ -15,7 +15,7 @@ from typing import List, Optional, Set
from hermes_cli.config import load_config, save_config
from hermes_cli.colors import Colors, color
from hermes_cli.platforms import PLATFORMS as _PLATFORMS, platform_label
from hermes_cli.platforms import PLATFORMS as _PLATFORMS
# Backward-compatible view: {key: label_string} so existing code that
# iterates ``PLATFORMS.items()`` or calls ``PLATFORMS.get(key)`` keeps
+17 -1
View File
@@ -335,7 +335,23 @@ def do_install(identifier: str, category: str = "", force: bool = False,
meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources)
if not bundle:
c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.\n")
# Check if any source hit GitHub API rate limit
rate_limited = any(
getattr(src, "is_rate_limited", False)
or getattr(getattr(src, "github", None), "is_rate_limited", False)
for src in sources
)
c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.")
if rate_limited:
c.print(
"[yellow]Hint:[/] GitHub API rate limit exhausted "
"(unauthenticated: 60 requests/hour).\n"
"Set [bold]GITHUB_TOKEN[/] in your .env or install the "
"[bold]gh[/] CLI and run [bold]gh auth login[/] "
"to raise the limit to 5,000/hr.\n"
)
else:
c.print()
return
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
-4
View File
@@ -126,10 +126,6 @@ class SkinConfig:
"""Get a color value with fallback."""
return self.colors.get(key, fallback)
def get_spinner_list(self, key: str) -> List[str]:
"""Get a spinner list (faces, verbs, etc.)."""
return self.spinner.get(key, [])
def get_spinner_wings(self) -> List[Tuple[str, str]]:
"""Get spinner wing pairs, or empty list if none."""
raw = self.spinner.get("wings", [])
+2 -4
View File
@@ -1,7 +1,7 @@
"""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,
@@ -346,6 +346,4 @@ def get_random_tip(exclude_recent: int = 0) -> str:
return random.choice(TIPS)
def get_tip_count() -> int:
"""Return the total number of tips available."""
return len(TIPS)
-1
View File
@@ -7,7 +7,6 @@ Provides options for:
"""
import os
import platform
import shutil
import subprocess
from pathlib import Path
File diff suppressed because it is too large Load Diff
-6
View File
@@ -237,10 +237,6 @@ def get_skills_dir() -> Path:
return get_hermes_home() / "skills"
def get_logs_dir() -> Path:
"""Return the path to the logs directory under HERMES_HOME."""
return get_hermes_home() / "logs"
def get_env_path() -> Path:
"""Return the path to the ``.env`` file under HERMES_HOME."""
@@ -296,5 +292,3 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
-9
View File
@@ -78,15 +78,6 @@ def set_session_context(session_id: str) -> None:
_session_context.session_id = session_id
def clear_session_context() -> None:
"""Clear the session ID for the current thread.
Optional ``set_session_context()`` overwrites the previous value,
so explicit clearing is only needed if the thread is reused for
non-conversation work after ``run_conversation()`` returns.
"""
_session_context.session_id = None
# ---------------------------------------------------------------------------
# Record factory — injects session_tag into every LogRecord at creation
@@ -1995,7 +1995,9 @@ class Migrator:
if compaction.get("timeout"):
pass # No direct mapping
if compaction.get("model"):
compression["summary_model"] = compaction["model"]
aux = hermes_cfg.setdefault("auxiliary", {})
aux_comp = aux.setdefault("compression", {})
aux_comp["model"] = compaction["model"]
hermes_cfg["compression"] = compression
changes = True
+3244 -364
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -19,6 +19,9 @@
"agent-browser": "^0.13.0",
"@askjo/camoufox-browser": "^1.0.0"
},
"overrides": {
"lodash": "4.18.1"
},
"engines": {
"node": ">=18.0.0"
}
+6 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.8.0"
version = "0.9.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
requires-python = ">=3.11"
@@ -76,6 +76,7 @@ termux = [
]
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
feishu = ["lark-oapi>=1.5.3,<2"]
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
@@ -107,6 +108,7 @@ all = [
"hermes-agent[dingtalk]",
"hermes-agent[feishu]",
"hermes-agent[mistral]",
"hermes-agent[web]",
]
[project.scripts]
@@ -117,6 +119,9 @@ hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
[tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
+393 -88
View File
@@ -460,6 +460,40 @@ def _sanitize_messages_non_ascii(messages: list) -> bool:
return found
def _sanitize_tools_non_ascii(tools: list) -> bool:
"""Strip non-ASCII characters from tool payloads in-place."""
return _sanitize_structure_non_ascii(tools)
def _sanitize_structure_non_ascii(payload: Any) -> bool:
"""Strip non-ASCII characters from nested dict/list payloads in-place."""
found = False
def _walk(node):
nonlocal found
if isinstance(node, dict):
for key, value in node.items():
if isinstance(value, str):
sanitized = _strip_non_ascii(value)
if sanitized != value:
node[key] = sanitized
found = True
elif isinstance(value, (dict, list)):
_walk(value)
elif isinstance(node, list):
for idx, value in enumerate(node):
if isinstance(value, str):
sanitized = _strip_non_ascii(value)
if sanitized != value:
node[idx] = sanitized
found = True
elif isinstance(value, (dict, list)):
_walk(value)
_walk(payload)
return found
@@ -675,9 +709,17 @@ class AIAgent:
# on /v1/chat/completions by both OpenAI and OpenRouter. Also
# auto-upgrade for direct OpenAI URLs (api.openai.com) since all
# newer tool-calling models prefer Responses there.
if self.api_mode == "chat_completions" and (
self._is_direct_openai_url()
or self._model_requires_responses_api(self.model)
# ACP runtimes are excluded: CopilotACPClient handles its own
# routing and does not implement the Responses API surface.
if (
self.api_mode == "chat_completions"
and self.provider != "copilot-acp"
and not str(self.base_url or "").lower().startswith("acp://copilot")
and not str(self.base_url or "").lower().startswith("acp+tcp://")
and (
self._is_direct_openai_url()
or self._model_requires_responses_api(self.model)
)
):
self.api_mode = "codex_responses"
@@ -737,6 +779,7 @@ class AIAgent:
self.service_tier = service_tier
self.request_overrides = dict(request_overrides or {})
self.prefill_messages = prefill_messages or [] # Prefilled conversation turns
self._force_ascii_payload = False
# Anthropic prompt caching: auto-enabled for Claude models via OpenRouter.
# Reduces input costs by ~75% on multi-turn conversations by caching the
@@ -1212,7 +1255,6 @@ class AIAgent:
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes")
compression_summary_model = _compression_cfg.get("summary_model") or None
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
@@ -1233,24 +1275,29 @@ class AIAgent:
# Check custom_providers per-model context_length
if _config_context_length is None:
_custom_providers = _agent_cfg.get("custom_providers")
if isinstance(_custom_providers, list):
for _cp_entry in _custom_providers:
if not isinstance(_cp_entry, dict):
continue
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == self.base_url.rstrip("/"):
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(self.model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
try:
_config_context_length = int(_cp_ctx)
except (TypeError, ValueError):
pass
break
try:
from hermes_cli.config import get_compatible_custom_providers
_custom_providers = get_compatible_custom_providers(_agent_cfg)
except Exception:
_custom_providers = _agent_cfg.get("custom_providers")
if not isinstance(_custom_providers, list):
_custom_providers = []
for _cp_entry in _custom_providers:
if not isinstance(_cp_entry, dict):
continue
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == self.base_url.rstrip("/"):
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(self.model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
try:
_config_context_length = int(_cp_ctx)
except (TypeError, ValueError):
pass
break
# Select context engine: config-driven (like memory providers).
# 1. Check config.yaml context.engine setting
@@ -1292,6 +1339,22 @@ class AIAgent:
if _selected_engine is not None:
self.context_compressor = _selected_engine
# Resolve context_length for plugin engines — mirrors switch_model() path
from agent.model_metadata import get_model_context_length
_plugin_ctx_len = get_model_context_length(
self.model,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
config_context_length=_config_context_length,
provider=self.provider,
)
self.context_compressor.update_model(
model=self.model,
context_length=_plugin_ctx_len,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
provider=self.provider,
)
if not self.quiet_mode:
logger.info("Using context engine: %s", _selected_engine.name)
else:
@@ -1301,7 +1364,7 @@ class AIAgent:
protect_first_n=3,
protect_last_n=compression_protect_last,
summary_target_ratio=compression_target_ratio,
summary_model_override=compression_summary_model,
summary_model_override=None,
quiet_mode=self.quiet_mode,
base_url=self.base_url,
api_key=getattr(self, "api_key", ""),
@@ -1748,10 +1811,25 @@ class AIAgent:
aux_base_url = str(getattr(client, "base_url", ""))
aux_api_key = str(getattr(client, "api_key", ""))
# Read user-configured context_length for the compression model.
# Custom endpoints often don't support /models API queries so
# get_model_context_length() falls through to the 128K default,
# ignoring the explicit config value. Pass it as the highest-
# priority hint so the configured value is always respected.
_aux_cfg = (self.config or {}).get("auxiliary", {}).get("compression", {})
_aux_context_config = _aux_cfg.get("context_length") if isinstance(_aux_cfg, dict) else None
if _aux_context_config is not None:
try:
_aux_context_config = int(_aux_context_config)
except (TypeError, ValueError):
_aux_context_config = None
aux_context = get_model_context_length(
aux_model,
base_url=aux_base_url,
api_key=aux_api_key,
config_context_length=_aux_context_config,
)
threshold = self.context_compressor.threshold_tokens
@@ -1872,12 +1950,13 @@ class AIAgent:
if not content:
return ""
# Strip all reasoning tag variants: <think>, <thinking>, <THINKING>,
# <reasoning>, <REASONING_SCRATCHPAD>
# <reasoning>, <REASONING_SCRATCHPAD>, <thought> (Gemma 4)
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL)
content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL)
content = re.sub(r'</?(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>\s*', '', content, flags=re.IGNORECASE)
content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*', '', content, flags=re.IGNORECASE)
return content
def _looks_like_codex_intermediate_ack(
@@ -2002,6 +2081,7 @@ class AIAgent:
inline_patterns = (
r"<think>(.*?)</think>",
r"<thinking>(.*?)</thinking>",
r"<thought>(.*?)</thought>",
r"<reasoning>(.*?)</reasoning>",
r"<REASONING_SCRATCHPAD>(.*?)</REASONING_SCRATCHPAD>",
)
@@ -4262,6 +4342,7 @@ class AIAgent:
try:
with active_client.responses.stream(**api_kwargs) as stream:
for event in stream:
self._touch_activity("receiving stream response")
if self._interrupt_requested:
break
event_type = getattr(event, "type", "")
@@ -4386,6 +4467,7 @@ class AIAgent:
collected_text_deltas: list = []
try:
for event in stream_or_response:
self._touch_activity("receiving stream response")
event_type = getattr(event, "type", None)
if not event_type and isinstance(event, dict):
event_type = event.get("type")
@@ -4688,6 +4770,11 @@ class AIAgent:
Each worker thread gets its own OpenAI client instance. Interrupts only
close that worker-local client, so retries and other requests never
inherit a closed transport.
Includes a stale-call detector: if no response arrives within the
configured timeout, the connection is killed and an error raised so
the main retry loop can try again with backoff / credential rotation /
provider fallback.
"""
result = {"response": None, "error": None}
request_client_holder = {"client": None}
@@ -4713,10 +4800,86 @@ class AIAgent:
if request_client is not None:
self._close_request_openai_client(request_client, reason="request_complete")
# ── Stale-call timeout (mirrors streaming stale detector) ────────
# Non-streaming calls return nothing until the full response is
# ready. Without this, a hung provider can block for the full
# httpx timeout (default 1800s) with zero feedback. The stale
# detector kills the connection early so the main retry loop can
# apply richer recovery (credential rotation, provider fallback).
_stale_base = float(os.getenv("HERMES_API_CALL_STALE_TIMEOUT", 300.0))
_base_url = getattr(self, "_base_url", None) or ""
if _stale_base == 300.0 and _base_url and is_local_endpoint(_base_url):
_stale_timeout = float("inf")
else:
_est_tokens = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
if _est_tokens > 100_000:
_stale_timeout = max(_stale_base, 600.0)
elif _est_tokens > 50_000:
_stale_timeout = max(_stale_base, 450.0)
else:
_stale_timeout = _stale_base
_call_start = time.time()
self._touch_activity("waiting for non-streaming API response")
t = threading.Thread(target=_call, daemon=True)
t.start()
_poll_count = 0
while t.is_alive():
t.join(timeout=0.3)
_poll_count += 1
# Touch activity every ~30s so the gateway's inactivity
# monitor knows we're alive while waiting for the response.
if _poll_count % 100 == 0: # 100 × 0.3s = 30s
_elapsed = time.time() - _call_start
self._touch_activity(
f"waiting for non-streaming response ({int(_elapsed)}s elapsed)"
)
# Stale-call detector: kill the connection if no response
# arrives within the configured timeout.
_elapsed = time.time() - _call_start
if _elapsed > _stale_timeout:
_est_ctx = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
logger.warning(
"Non-streaming API call stale for %.0fs (threshold %.0fs). "
"model=%s context=~%s tokens. Killing connection.",
_elapsed, _stale_timeout,
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
)
self._emit_status(
f"⚠️ No response from provider for {int(_elapsed)}s "
f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). "
f"Aborting call."
)
try:
if self.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
self._anthropic_client.close()
self._anthropic_client = build_anthropic_client(
self._anthropic_api_key,
getattr(self, "_anthropic_base_url", None),
)
else:
rc = request_client_holder.get("client")
if rc is not None:
self._close_request_openai_client(rc, reason="stale_call_kill")
except Exception:
pass
self._touch_activity(
f"stale non-streaming call killed after {int(_elapsed)}s"
)
# Wait briefly for the thread to notice the closed connection.
t.join(timeout=2.0)
if result["error"] is None and result["response"] is None:
result["error"] = TimeoutError(
f"Non-streaming API call timed out after {int(_elapsed)}s "
f"with no response (threshold: {int(_stale_timeout)}s)"
)
break
if self._interrupt_requested:
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
@@ -4937,12 +5100,9 @@ class AIAgent:
role = "assistant"
reasoning_parts: list = []
usage_obj = None
_first_chunk_seen = False
for chunk in stream:
last_chunk_time["t"] = time.time()
if not _first_chunk_seen:
_first_chunk_seen = True
self._touch_activity("receiving stream response")
self._touch_activity("receiving stream response")
if self._interrupt_requested:
break
@@ -5118,6 +5278,7 @@ class AIAgent:
# actively arriving (the chat_completions path
# already does this at the top of its chunk loop).
last_chunk_time["t"] = time.time()
self._touch_activity("receiving stream response")
if self._interrupt_requested:
break
@@ -5231,6 +5392,10 @@ class AIAgent:
f"({type(e).__name__}). Reconnecting… "
f"(attempt {_stream_attempt + 2}/{_max_stream_retries + 1})"
)
self._touch_activity(
f"stream retry {_stream_attempt + 2}/{_max_stream_retries + 1} "
f"after {type(e).__name__}"
)
# Close the stale request client before retry
stale = request_client_holder.get("client")
if stale is not None:
@@ -5254,8 +5419,7 @@ class AIAgent:
"try again in a moment."
)
logger.warning(
"Streaming exhausted %s retries on transient error, "
"falling back to non-streaming: %s",
"Streaming exhausted %s retries on transient error: %s",
_max_stream_retries + 1,
e,
)
@@ -5266,25 +5430,24 @@ class AIAgent:
and "not supported" in _err_lower
)
if _is_stream_unsupported:
self._disable_streaming = True
self._safe_print(
"\n⚠ Streaming is not supported for this "
"model/provider. Falling back to non-streaming.\n"
"model/provider. Switching to non-streaming.\n"
" To avoid this delay, set display.streaming: false "
"in config.yaml\n"
)
logger.info(
"Streaming failed before delivery, falling back to non-streaming: %s",
"Streaming failed before delivery: %s",
e,
)
try:
# Reset stale timer — the non-streaming fallback
# uses its own client; prevent the stale detector
# from firing on stale timestamps from failed streams.
last_chunk_time["t"] = time.time()
result["response"] = self._interruptible_api_call(api_kwargs)
except Exception as fallback_err:
result["error"] = fallback_err
# Propagate the error to the main retry loop instead of
# falling back to non-streaming inline. The main loop has
# richer recovery: credential rotation, provider fallback,
# backoff, and — for "stream not supported" — will switch
# to non-streaming on the next attempt via _disable_streaming.
result["error"] = e
return
finally:
request_client = request_client_holder.get("client")
@@ -5350,6 +5513,9 @@ class AIAgent:
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
self._touch_activity(
f"stale stream detected after {int(_stale_elapsed)}s, reconnecting"
)
if self._interrupt_requested:
try:
@@ -5375,13 +5541,22 @@ class AIAgent:
# a new API call, creating a duplicate message. Return a
# partial "stop" response instead so the outer loop treats this
# turn as complete (no retry, no fallback).
# Recover whatever content was already streamed to the user.
# _current_streamed_assistant_text accumulates text fired
# through _fire_stream_delta, so it has exactly what the
# user saw before the connection died.
_partial_text = (
getattr(self, "_current_streamed_assistant_text", "") or ""
).strip() or None
logger.warning(
"Partial stream delivered before error; returning stub "
"response to prevent duplicate messages: %s",
"response with %s chars of recovered content to prevent "
"duplicate messages: %s",
len(_partial_text or ""),
result["error"],
)
_stub_msg = SimpleNamespace(
role="assistant", content=None, tool_calls=None,
role="assistant", content=_partial_text, tool_calls=None,
reasoning_content=None,
)
return SimpleNamespace(
@@ -5840,11 +6015,12 @@ class AIAgent:
"""True when using an anthropic-compatible endpoint that preserves dots in model names.
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
MiniMax keeps dots (e.g. MiniMax-M2.7).
OpenCode Go keeps dots (e.g. minimax-m2.7)."""
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go"}:
OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).
ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1)."""
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai"}:
return True
base = (getattr(self, "base_url", "") or "").lower()
return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/go" in base
return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/" in base or "bigmodel.cn" in base
def _is_qwen_portal(self) -> bool:
"""Return True when the base URL targets Qwen Portal."""
@@ -8077,6 +8253,8 @@ class AIAgent:
try:
self._reset_stream_delivery_tracking()
api_kwargs = self._build_api_kwargs(api_messages)
if self._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
if self.api_mode == "codex_responses":
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
@@ -8124,7 +8302,12 @@ class AIAgent:
self.thinking_callback("")
_use_streaming = True
if not self._has_stream_consumers():
# Provider signaled "stream not supported" on a previous
# attempt — switch to non-streaming for the rest of this
# session instead of re-failing every retry.
if getattr(self, "_disable_streaming", False):
_use_streaming = False
elif not self._has_stream_consumers():
# No display/TTS consumer. Still prefer streaming for
# health checking, but skip for Mock clients in tests
# (mocks return SimpleNamespace, not stream iterators).
@@ -8224,7 +8407,8 @@ class AIAgent:
if self.thinking_callback:
self.thinking_callback("")
# This is often rate limiting or provider returning malformed response
# Invalid response — could be rate limiting, provider timeout,
# upstream server error, or malformed response.
retry_count += 1
# Eager fallback: empty/malformed responses are a common
@@ -8260,11 +8444,44 @@ class AIAgent:
if self.verbose_logging:
logging.debug(f"Response attributes for invalid response: {resp_attrs}")
# Extract error code from response for contextual diagnostics
_resp_error_code = None
if response and hasattr(response, 'error') and response.error:
_code_raw = getattr(response.error, 'code', None)
if _code_raw is None and isinstance(response.error, dict):
_code_raw = response.error.get('code')
if _code_raw is not None:
try:
_resp_error_code = int(_code_raw)
except (TypeError, ValueError):
pass
# Build a human-readable failure hint from the error code
# and response time, instead of always assuming rate limiting.
if _resp_error_code == 524:
_failure_hint = f"upstream provider timed out (Cloudflare 524, {api_duration:.0f}s)"
elif _resp_error_code == 504:
_failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)"
elif _resp_error_code == 429:
_failure_hint = f"rate limited by upstream provider (429)"
elif _resp_error_code in (500, 502):
_failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)"
elif _resp_error_code in (503, 529):
_failure_hint = f"upstream provider overloaded ({_resp_error_code})"
elif _resp_error_code is not None:
_failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)"
elif api_duration < 10:
_failure_hint = f"fast response ({api_duration:.1f}s) — likely rate limited"
elif api_duration > 60:
_failure_hint = f"slow response ({api_duration:.0f}s) — likely upstream timeout"
else:
_failure_hint = f"response time {api_duration:.1f}s"
self._vprint(f"{self.log_prefix}⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}", force=True)
self._vprint(f"{self.log_prefix} 🏢 Provider: {provider_name}", force=True)
cleaned_provider_error = self._clean_error_message(error_msg)
self._vprint(f"{self.log_prefix} 📝 Provider message: {cleaned_provider_error}", force=True)
self._vprint(f"{self.log_prefix} ⏱️ Response time: {api_duration:.2f}s (fast response often indicates rate limiting)", force=True)
self._vprint(f"{self.log_prefix} ⏱️ {_failure_hint}", force=True)
if retry_count >= max_retries:
# Try fallback before giving up
@@ -8281,31 +8498,39 @@ class AIAgent:
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": "Invalid API response shape. Likely rate limited or malformed provider response.",
"error": f"Invalid API response after {max_retries} retries: {_failure_hint}",
"failed": True # Mark as failure for filtering
}
# Longer backoff for rate limiting (likely cause of None choices)
# Jittered exponential: 5s base, 120s cap + random jitter
# Backoff before retry — jittered exponential: 5s base, 120s cap
wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)
self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...", force=True)
self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...", force=True)
logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}")
# Sleep in small increments to stay responsive to interrupts
sleep_end = time.time() + wait_time
_backoff_touch_counter = 0
while time.time() < sleep_end:
if self._interrupt_requested:
self._vprint(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
self._persist_session(messages, conversation_history)
self.clear_interrupt()
return {
"final_response": f"Operation interrupted: retrying API call after rate limit (retry {retry_count}/{max_retries}).",
"final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"interrupted": True,
}
time.sleep(0.2)
# Touch activity every ~30s so the gateway's inactivity
# monitor knows we're alive during backoff waits.
_backoff_touch_counter += 1
if _backoff_touch_counter % 150 == 0: # 150 × 0.2s = 30s
self._touch_activity(
f"retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
continue # Retry the API call
# Check finish_reason before proceeding
@@ -8660,18 +8885,56 @@ class AIAgent:
)
continue
if _is_ascii_codec:
self._force_ascii_payload = True
# ASCII codec: the system encoding can't handle
# non-ASCII characters at all. Sanitize all
# non-ASCII content from messages and retry.
if _sanitize_messages_non_ascii(messages):
# non-ASCII content from messages/tool schemas and retry.
_messages_sanitized = _sanitize_messages_non_ascii(messages)
_prefill_sanitized = False
if isinstance(getattr(self, "prefill_messages", None), list):
_prefill_sanitized = _sanitize_messages_non_ascii(self.prefill_messages)
_tools_sanitized = False
if isinstance(getattr(self, "tools", None), list):
_tools_sanitized = _sanitize_tools_non_ascii(self.tools)
_system_sanitized = False
if isinstance(active_system_prompt, str):
_sanitized_system = _strip_non_ascii(active_system_prompt)
if _sanitized_system != active_system_prompt:
active_system_prompt = _sanitized_system
self._cached_system_prompt = _sanitized_system
_system_sanitized = True
if isinstance(getattr(self, "ephemeral_system_prompt", None), str):
_sanitized_ephemeral = _strip_non_ascii(self.ephemeral_system_prompt)
if _sanitized_ephemeral != self.ephemeral_system_prompt:
self.ephemeral_system_prompt = _sanitized_ephemeral
_system_sanitized = True
_headers_sanitized = False
_default_headers = (
self._client_kwargs.get("default_headers")
if isinstance(getattr(self, "_client_kwargs", None), dict)
else None
)
if isinstance(_default_headers, dict):
_headers_sanitized = _sanitize_structure_non_ascii(_default_headers)
if (
_messages_sanitized
or _prefill_sanitized
or _tools_sanitized
or _system_sanitized
or _headers_sanitized
):
self._unicode_sanitization_passes += 1
self._vprint(
f"{self.log_prefix}⚠️ System encoding is ASCII — stripped non-ASCII characters from messages. Retrying...",
f"{self.log_prefix}⚠️ System encoding is ASCII — stripped non-ASCII characters from request payload. Retrying...",
force=True,
)
continue
# Nothing to sanitize in messages — might be in system
# prompt or prefill. Fall through to normal error path.
# Nothing to sanitize in any payload component.
# Fall through to normal error path.
status_code = getattr(api_error, "status_code", None)
error_context = self._extract_api_error_context(api_error)
@@ -8778,6 +9041,9 @@ class AIAgent:
retry_count += 1
elapsed_time = time.time() - api_start_time
self._touch_activity(
f"API error recovery (attempt {retry_count}/{max_retries})"
)
error_type = type(api_error).__name__
error_msg = str(api_error).lower()
@@ -9304,6 +9570,7 @@ class AIAgent:
# Sleep in small increments so we can respond to interrupts quickly
# instead of blocking the entire wait_time in one sleep() call
sleep_end = time.time() + wait_time
_backoff_touch_counter = 0
while time.time() < sleep_end:
if self._interrupt_requested:
self._vprint(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
@@ -9317,6 +9584,14 @@ class AIAgent:
"interrupted": True,
}
time.sleep(0.2) # Check interrupt every 200ms
# Touch activity every ~30s so the gateway's inactivity
# monitor knows we're alive during backoff waits.
_backoff_touch_counter += 1
if _backoff_touch_counter % 150 == 0: # 150 × 0.2s = 30s
self._touch_activity(
f"error retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
# If the API call was interrupted, skip response processing
if interrupted:
@@ -9702,12 +9977,25 @@ class AIAgent:
# Pop thinking-only prefill message(s) before appending
# (tool-call path — same rationale as the final-response path).
_had_prefill = False
while (
messages
and isinstance(messages[-1], dict)
and messages[-1].get("_thinking_prefill")
):
messages.pop()
_had_prefill = True
# Reset prefill counter when tool calls follow a prefill
# recovery. Without this, the counter accumulates across
# the whole conversation — a model that intermittently
# empties (empty → prefill → tools → empty → prefill →
# tools) burns both prefill attempts and the third empty
# gets zero recovery. Resetting here treats each tool-
# call success as a fresh start.
if _had_prefill:
self._thinking_prefill_retries = 0
self._empty_content_retries = 0
messages.append(assistant_msg)
self._emit_interim_assistant_message(assistant_msg)
@@ -9826,6 +10114,30 @@ class AIAgent:
# Check if response only has think block with no actual content after it
if not self._has_content_after_think_block(final_response):
# ── Partial stream recovery ─────────────────────
# If content was already streamed to the user before
# the connection died, use it as the final response
# instead of falling through to prior-turn fallback
# or wasting API calls on retries.
_partial_streamed = (
getattr(self, "_current_streamed_assistant_text", "") or ""
)
if self._has_content_after_think_block(_partial_streamed):
_turn_exit_reason = "partial_stream_recovery"
_recovered = self._strip_think_blocks(_partial_streamed).strip()
logger.info(
"Partial stream content delivered (%d chars) "
"— using as final response",
len(_recovered),
)
self._emit_status(
"↻ Stream interrupted — using delivered content "
"as final response"
)
final_response = _recovered
self._response_was_previewed = True
break
# If the previous turn already delivered real content alongside
# tool calls (e.g. "You're welcome!" + memory save), the model
# has nothing more to say. Use the earlier content immediately
@@ -9883,16 +10195,23 @@ class AIAgent:
self._save_session_log(messages)
continue
# ── Empty response retry (no reasoning) ──────
# Model returned nothing — no content, no
# structured reasoning, no tool calls. Common
# with open models (transient provider issues,
# rate limits, sampling flukes). Retry up to 3
# times before attempting fallback. Skip when
# content has inline <think> tags (model chose
# to reason, just no visible text).
_truly_empty = not final_response.strip()
if _truly_empty and not _has_structured and self._empty_content_retries < 3:
# ── Empty response retry ──────────────────────
# Model returned nothing usable. Retry up to 3
# times before attempting fallback. This covers
# both truly empty responses (no content, no
# reasoning) AND reasoning-only responses after
# prefill exhaustion — models like mimo-v2-pro
# always populate reasoning fields via OpenRouter,
# so the old `not _has_structured` guard blocked
# retries for every reasoning model after prefill.
_truly_empty = not self._strip_think_blocks(
final_response
).strip()
_prefill_exhausted = (
_has_structured
and self._thinking_prefill_retries >= 2
)
if _truly_empty and (not _has_structured or _prefill_exhausted) and self._empty_content_retries < 3:
self._empty_content_retries += 1
logger.warning(
"Empty response (no content or reasoning) — "
@@ -10086,17 +10405,11 @@ class AIAgent:
if final_response is None and (
api_call_count >= self.max_iterations
or self.iteration_budget.remaining <= 0
) and not self._budget_exhausted_injected:
# Budget exhausted but we haven't tried asking the model to
# summarise yet. Inject a user message and give it one grace
# API call to produce a text response.
self._budget_exhausted_injected = True
self._budget_grace_call = True
_grace_msg = (
"Your tool budget ran out. Please give me the information "
"or actions you've completed so far."
)
messages.append({"role": "user", "content": _grace_msg})
):
# Budget exhausted ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{self.max_iterations})"
self._emit_status(
f"⚠️ Iteration budget exhausted ({api_call_count}/{self.max_iterations}) "
"— asking model to summarise"
@@ -10106,14 +10419,6 @@ class AIAgent:
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{self.max_iterations}) "
"— requesting summary..."
)
if final_response is None and (
api_call_count >= self.max_iterations
or self.iteration_budget.remaining <= 0
) and not self._budget_grace_call:
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{self.max_iterations})"
if self.iteration_budget.remaining <= 0 and not self.quiet_mode:
print(f"\n⚠️ Iteration budget exhausted ({self.iteration_budget.used}/{self.iteration_budget.max_total} iterations used)")
final_response = self._handle_max_iterations(messages, api_call_count)
# Determine if conversation completed successfully
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Build the Hermes Skills Index — a centralized JSON catalog of all skills.
This script crawls every skill source (skills.sh, GitHub taps, official,
clawhub, lobehub, claude-marketplace) and writes a JSON index with resolved
GitHub paths. The index is served as a static file on the docs site so that
`hermes skills search/install` can use it without hitting the GitHub API.
Usage:
# Local (uses gh CLI or GITHUB_TOKEN for auth)
python scripts/build_skills_index.py
# CI (set GITHUB_TOKEN as secret)
GITHUB_TOKEN=ghp_... python scripts/build_skills_index.py
Output: website/static/api/skills-index.json
"""
import json
import os
import sys
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
# Allow importing from repo root
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO_ROOT)
# Ensure HERMES_HOME is set (needed by tools/skills_hub.py imports)
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
from tools.skills_hub import (
GitHubAuth,
GitHubSource,
SkillsShSource,
OptionalSkillSource,
WellKnownSkillSource,
ClawHubSource,
ClaudeMarketplaceSource,
LobeHubSource,
SkillMeta,
)
import httpx
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json")
INDEX_VERSION = 1
def _meta_to_dict(meta: SkillMeta) -> dict:
"""Convert a SkillMeta to a serializable dict."""
return {
"name": meta.name,
"description": meta.description,
"source": meta.source,
"identifier": meta.identifier,
"trust_level": meta.trust_level,
"repo": meta.repo or "",
"path": meta.path or "",
"tags": meta.tags or [],
"extra": meta.extra or {},
}
def crawl_source(source, source_name: str, limit: int) -> list:
"""Crawl a single source and return skill dicts."""
print(f" Crawling {source_name}...", flush=True)
start = time.time()
try:
results = source.search("", limit=limit)
except Exception as e:
print(f" Error crawling {source_name}: {e}", file=sys.stderr)
return []
skills = [_meta_to_dict(m) for m in results]
elapsed = time.time() - start
print(f" {source_name}: {len(skills)} skills ({elapsed:.1f}s)", flush=True)
return skills
def crawl_skills_sh(source: SkillsShSource) -> list:
"""Crawl skills.sh using popular queries for broad coverage."""
print(" Crawling skills.sh (popular queries)...", flush=True)
start = time.time()
queries = [
"", # featured
"react", "python", "web", "api", "database", "docker",
"testing", "scraping", "design", "typescript", "git",
"aws", "security", "data", "ml", "ai", "devops",
"frontend", "backend", "mobile", "cli", "documentation",
"kubernetes", "terraform", "rust", "go", "java",
]
all_skills: dict[str, dict] = {}
for query in queries:
try:
results = source.search(query, limit=50)
for meta in results:
entry = _meta_to_dict(meta)
if entry["identifier"] not in all_skills:
all_skills[entry["identifier"]] = entry
except Exception as e:
print(f" Warning: skills.sh search '{query}' failed: {e}",
file=sys.stderr)
elapsed = time.time() - start
print(f" skills.sh: {len(all_skills)} unique skills ({elapsed:.1f}s)",
flush=True)
return list(all_skills.values())
def _fetch_repo_tree(repo: str, auth: GitHubAuth) -> list:
"""Fetch the recursive tree for a repo. Returns list of tree entries."""
headers = auth.get_headers()
try:
resp = httpx.get(
f"https://api.github.com/repos/{repo}",
headers=headers, timeout=15, follow_redirects=True,
)
if resp.status_code != 200:
return []
branch = resp.json().get("default_branch", "main")
resp = httpx.get(
f"https://api.github.com/repos/{repo}/git/trees/{branch}",
params={"recursive": "1"},
headers=headers, timeout=30, follow_redirects=True,
)
if resp.status_code != 200:
return []
data = resp.json()
if data.get("truncated"):
return []
return data.get("tree", [])
except Exception:
return []
def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list:
"""Resolve GitHub paths for skills.sh entries using batch tree lookups.
Instead of resolving each skill individually (N×M API calls), we:
1. Group skills by repo
2. Fetch one tree per repo (2 API calls per repo)
3. Find all SKILL.md files in the tree
4. Match skills to their resolved paths
"""
# Filter to skills.sh entries that need resolution
skills_sh = [s for s in skills if s["source"] in ("skills.sh", "skills-sh")]
if not skills_sh:
return skills
print(f" Resolving paths for {len(skills_sh)} skills.sh entries...",
flush=True)
start = time.time()
# Group by repo
by_repo: dict[str, list] = defaultdict(list)
for s in skills_sh:
repo = s.get("repo", "")
if repo:
by_repo[repo].append(s)
print(f" {len(by_repo)} unique repos to scan", flush=True)
resolved_count = 0
# Fetch trees in parallel (up to 6 concurrent)
def _resolve_repo(repo: str, entries: list):
tree = _fetch_repo_tree(repo, auth)
if not tree:
return 0
# Find all SKILL.md paths in this repo
skill_paths = {} # skill_dir_name -> full_path
for item in tree:
if item.get("type") != "blob":
continue
path = item.get("path", "")
if path.endswith("/SKILL.md"):
skill_dir = path[: -len("/SKILL.md")]
dir_name = skill_dir.split("/")[-1]
skill_paths[dir_name.lower()] = f"{repo}/{skill_dir}"
# Also check SKILL.md frontmatter name if we can match by path
# For now, just index by directory name
elif path == "SKILL.md":
# Root-level SKILL.md
skill_paths["_root_"] = f"{repo}"
count = 0
for entry in entries:
# Try to match the skill's name/path to a tree entry
skill_name = entry.get("name", "").lower()
skill_path = entry.get("path", "").lower()
identifier = entry.get("identifier", "")
# Extract the skill token from the identifier
# e.g. "skills-sh/d4vinci/scrapling/scrapling-official" -> "scrapling-official"
parts = identifier.replace("skills-sh/", "").replace("skills.sh/", "")
skill_token = parts.split("/")[-1].lower() if "/" in parts else ""
# Try matching in order of likelihood
for candidate in [skill_token, skill_name, skill_path]:
if not candidate:
continue
matched = skill_paths.get(candidate)
if matched:
entry["resolved_github_id"] = matched
count += 1
break
else:
# Try fuzzy: skill_token with common transformations
for tree_name, tree_path in skill_paths.items():
if (skill_token and (
tree_name.replace("-", "") == skill_token.replace("-", "")
or skill_token in tree_name
or tree_name in skill_token
)):
entry["resolved_github_id"] = tree_path
count += 1
break
return count
with ThreadPoolExecutor(max_workers=6) as pool:
futures = {
pool.submit(_resolve_repo, repo, entries): repo
for repo, entries in by_repo.items()
}
for future in as_completed(futures):
try:
resolved_count += future.result()
except Exception as e:
repo = futures[future]
print(f" Warning: {repo}: {e}", file=sys.stderr)
elapsed = time.time() - start
print(f" Resolved {resolved_count}/{len(skills_sh)} paths ({elapsed:.1f}s)",
flush=True)
return skills
def main():
print("Building Hermes Skills Index...", flush=True)
overall_start = time.time()
auth = GitHubAuth()
print(f"GitHub auth: {auth.auth_method()}")
if auth.auth_method() == "anonymous":
print("WARNING: No GitHub authentication — rate limit is 60/hr. "
"Set GITHUB_TOKEN for better results.", file=sys.stderr)
skills_sh_source = SkillsShSource(auth=auth)
sources = {
"official": OptionalSkillSource(),
"well-known": WellKnownSkillSource(),
"github": GitHubSource(auth=auth),
"clawhub": ClawHubSource(),
"claude-marketplace": ClaudeMarketplaceSource(auth=auth),
"lobehub": LobeHubSource(),
}
all_skills: list[dict] = []
# Crawl skills.sh
all_skills.extend(crawl_skills_sh(skills_sh_source))
# Crawl other sources in parallel
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {}
for name, source in sources.items():
futures[pool.submit(crawl_source, source, name, 500)] = name
for future in as_completed(futures):
try:
all_skills.extend(future.result())
except Exception as e:
print(f" Error: {e}", file=sys.stderr)
# Batch resolve GitHub paths for skills.sh entries
all_skills = batch_resolve_paths(all_skills, auth)
# Deduplicate by identifier
seen: dict[str, dict] = {}
for skill in all_skills:
key = skill["identifier"]
if key not in seen:
seen[key] = skill
deduped = list(seen.values())
# Sort
source_order = {"official": 0, "skills-sh": 1, "skills.sh": 1,
"github": 2, "well-known": 3, "clawhub": 4,
"claude-marketplace": 5, "lobehub": 6}
deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"]))
# Build index
index = {
"version": INDEX_VERSION,
"generated_at": datetime.now(timezone.utc).isoformat(),
"skill_count": len(deduped),
"skills": deduped,
}
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w") as f:
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
elapsed = time.time() - overall_start
file_size = os.path.getsize(OUTPUT_PATH)
print(f"\nDone! {len(deduped)} skills indexed in {elapsed:.0f}s")
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
from collections import Counter
by_source = Counter(s["source"] for s in deduped)
for src, count in sorted(by_source.items(), key=lambda x: -x[1]):
resolved = sum(1 for s in deduped
if s["source"] == src and s.get("resolved_github_id"))
extra = f" ({resolved} resolved)" if resolved else ""
print(f" {src}: {count}{extra}")
if __name__ == "__main__":
main()
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Contributor Audit Script
Cross-references git authors, Co-authored-by trailers, and salvaged PR
descriptions to find any contributors missing from the release notes.
Usage:
# Basic audit since a tag
python scripts/contributor_audit.py --since-tag v2026.4.8
# Audit with a custom endpoint
python scripts/contributor_audit.py --since-tag v2026.4.8 --until v2026.4.13
# Compare against a release notes file
python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
# ---------------------------------------------------------------------------
# Import AUTHOR_MAP and resolve_author from the sibling release.py module
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from release import AUTHOR_MAP, resolve_author # noqa: E402
REPO_ROOT = SCRIPT_DIR.parent
# ---------------------------------------------------------------------------
# AI assistants, bots, and machine accounts to exclude from contributor lists
# ---------------------------------------------------------------------------
IGNORED_PATTERNS = [
re.compile(r"^Claude", re.IGNORECASE),
re.compile(r"^Copilot$", re.IGNORECASE),
re.compile(r"^Cursor\s+Agent$", re.IGNORECASE),
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
re.compile(r"^dependabot", re.IGNORECASE),
re.compile(r"^renovate", re.IGNORECASE),
re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE),
re.compile(r"^Ubuntu$", re.IGNORECASE),
]
IGNORED_EMAILS = {
"noreply@anthropic.com",
"noreply@github.com",
"cursoragent@cursor.com",
"hermes@nousresearch.com",
"hermes-audit@example.com",
"hermes@habibilabs.dev",
}
def is_ignored(handle: str, email: str = "") -> bool:
"""Return True if this contributor is a bot/AI/machine account."""
if email in IGNORED_EMAILS:
return True
for pattern in IGNORED_PATTERNS:
if pattern.search(handle):
return True
return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def git(*args, cwd=None):
"""Run a git command and return stdout."""
result = subprocess.run(
["git"] + list(args),
capture_output=True,
text=True,
cwd=cwd or str(REPO_ROOT),
)
if result.returncode != 0:
print(f" [warn] git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr)
return ""
return result.stdout.strip()
def gh_pr_list():
"""Fetch merged PRs from GitHub using the gh CLI.
Returns a list of dicts with keys: number, title, body, author.
Returns an empty list if gh is not available or the call fails.
"""
try:
result = subprocess.run(
[
"gh", "pr", "list",
"--repo", "NousResearch/hermes-agent",
"--state", "merged",
"--json", "number,title,body,author,mergedAt",
"--limit", "300",
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
print(f" [warn] gh pr list failed: {result.stderr.strip()}", file=sys.stderr)
return []
return json.loads(result.stdout)
except FileNotFoundError:
print(" [warn] 'gh' CLI not found — skipping salvaged PR scan.", file=sys.stderr)
return []
except subprocess.TimeoutExpired:
print(" [warn] gh pr list timed out — skipping salvaged PR scan.", file=sys.stderr)
return []
except json.JSONDecodeError:
print(" [warn] gh pr list returned invalid JSON — skipping salvaged PR scan.", file=sys.stderr)
return []
# ---------------------------------------------------------------------------
# Contributor collection
# ---------------------------------------------------------------------------
# Patterns that indicate salvaged/cherry-picked/co-authored work in PR bodies
SALVAGE_PATTERNS = [
# "Salvaged from @username" or "Salvaged from #123"
re.compile(r"[Ss]alvaged\s+from\s+@(\w[\w-]*)"),
re.compile(r"[Ss]alvaged\s+from\s+#(\d+)"),
# "Cherry-picked from @username"
re.compile(r"[Cc]herry[- ]?picked\s+from\s+@(\w[\w-]*)"),
# "Based on work by @username"
re.compile(r"[Bb]ased\s+on\s+work\s+by\s+@(\w[\w-]*)"),
# "Original PR by @username"
re.compile(r"[Oo]riginal\s+PR\s+by\s+@(\w[\w-]*)"),
# "Co-authored with @username"
re.compile(r"[Cc]o[- ]?authored\s+with\s+@(\w[\w-]*)"),
]
# Pattern for Co-authored-by trailers in commit messages
CO_AUTHORED_RE = re.compile(
r"Co-authored-by:\s*(.+?)\s*<([^>]+)>",
re.IGNORECASE,
)
def collect_commit_authors(since_tag, until="HEAD"):
"""Collect contributors from git commit authors.
Returns:
contributors: dict mapping github_handle -> set of source labels
unknown_emails: dict mapping email -> git name (for emails not in AUTHOR_MAP)
"""
range_spec = f"{since_tag}..{until}"
log = git(
"log", range_spec,
"--format=%H|%an|%ae|%s",
"--no-merges",
)
contributors = defaultdict(set)
unknown_emails = {}
if not log:
return contributors, unknown_emails
for line in log.split("\n"):
if not line.strip():
continue
parts = line.split("|", 3)
if len(parts) != 4:
continue
_sha, name, email, _subject = parts
handle = resolve_author(name, email)
# resolve_author returns "@handle" or plain name
if handle.startswith("@"):
contributors[handle.lstrip("@")].add("commit")
else:
# Could not resolve — record as unknown
contributors[handle].add("commit")
unknown_emails[email] = name
return contributors, unknown_emails
def collect_co_authors(since_tag, until="HEAD"):
"""Collect contributors from Co-authored-by trailers in commit messages.
Returns:
contributors: dict mapping github_handle -> set of source labels
unknown_emails: dict mapping email -> git name
"""
range_spec = f"{since_tag}..{until}"
# Get full commit messages to scan for trailers
log = git(
"log", range_spec,
"--format=__COMMIT__%H%n%b",
"--no-merges",
)
contributors = defaultdict(set)
unknown_emails = {}
if not log:
return contributors, unknown_emails
for line in log.split("\n"):
match = CO_AUTHORED_RE.search(line)
if match:
name = match.group(1).strip()
email = match.group(2).strip()
handle = resolve_author(name, email)
if handle.startswith("@"):
contributors[handle.lstrip("@")].add("co-author")
else:
contributors[handle].add("co-author")
unknown_emails[email] = name
return contributors, unknown_emails
def collect_salvaged_contributors(since_tag, until="HEAD"):
"""Scan merged PR bodies for salvage/cherry-pick/co-author attribution.
Uses the gh CLI to fetch PRs, then filters to the date range defined
by since_tag..until and scans bodies for salvage patterns.
Returns:
contributors: dict mapping github_handle -> set of source labels
pr_refs: dict mapping github_handle -> list of PR numbers where found
"""
contributors = defaultdict(set)
pr_refs = defaultdict(list)
# Determine the date range from git tags/refs
since_date = git("log", "-1", "--format=%aI", since_tag)
if until == "HEAD":
until_date = git("log", "-1", "--format=%aI", "HEAD")
else:
until_date = git("log", "-1", "--format=%aI", until)
if not since_date:
print(f" [warn] Could not resolve date for {since_tag}", file=sys.stderr)
return contributors, pr_refs
prs = gh_pr_list()
if not prs:
return contributors, pr_refs
for pr in prs:
# Filter by merge date if available
merged_at = pr.get("mergedAt", "")
if merged_at and since_date:
if merged_at < since_date:
continue
if until_date and merged_at > until_date:
continue
body = pr.get("body") or ""
pr_number = pr.get("number", "?")
# Also credit the PR author
pr_author = pr.get("author", {})
pr_author_login = pr_author.get("login", "") if isinstance(pr_author, dict) else ""
for pattern in SALVAGE_PATTERNS:
for match in pattern.finditer(body):
value = match.group(1)
# If it's a number, it's a PR reference — skip for now
# (would need another API call to resolve PR author)
if value.isdigit():
continue
contributors[value].add("salvage")
pr_refs[value].append(pr_number)
return contributors, pr_refs
# ---------------------------------------------------------------------------
# Release file comparison
# ---------------------------------------------------------------------------
def check_release_file(release_file, all_contributors):
"""Check which contributors are mentioned in the release file.
Returns:
mentioned: set of handles found in the file
missing: set of handles NOT found in the file
"""
try:
content = Path(release_file).read_text()
except FileNotFoundError:
print(f" [error] Release file not found: {release_file}", file=sys.stderr)
return set(), set(all_contributors)
mentioned = set()
missing = set()
content_lower = content.lower()
for handle in all_contributors:
# Check for @handle or just handle (case-insensitive)
if f"@{handle.lower()}" in content_lower or handle.lower() in content_lower:
mentioned.add(handle)
else:
missing.add(handle)
return mentioned, missing
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Audit contributors across git history, co-author trailers, and salvaged PRs.",
)
parser.add_argument(
"--since-tag",
required=True,
help="Git tag to start from (e.g., v2026.4.8)",
)
parser.add_argument(
"--until",
default="HEAD",
help="Git ref to end at (default: HEAD)",
)
parser.add_argument(
"--release-file",
default=None,
help="Path to a release notes file to check for missing contributors",
)
args = parser.parse_args()
print(f"=== Contributor Audit: {args.since_tag}..{args.until} ===")
print()
# ---- 1. Git commit authors ----
print("[1/3] Scanning git commit authors...")
commit_contribs, commit_unknowns = collect_commit_authors(args.since_tag, args.until)
print(f" Found {len(commit_contribs)} contributor(s) from commits.")
# ---- 2. Co-authored-by trailers ----
print("[2/3] Scanning Co-authored-by trailers...")
coauthor_contribs, coauthor_unknowns = collect_co_authors(args.since_tag, args.until)
print(f" Found {len(coauthor_contribs)} contributor(s) from co-author trailers.")
# ---- 3. Salvaged PRs ----
print("[3/3] Scanning salvaged/cherry-picked PR descriptions...")
salvage_contribs, salvage_pr_refs = collect_salvaged_contributors(args.since_tag, args.until)
print(f" Found {len(salvage_contribs)} contributor(s) from salvaged PRs.")
# ---- Merge all contributors ----
all_contributors = defaultdict(set)
for handle, sources in commit_contribs.items():
all_contributors[handle].update(sources)
for handle, sources in coauthor_contribs.items():
all_contributors[handle].update(sources)
for handle, sources in salvage_contribs.items():
all_contributors[handle].update(sources)
# Merge unknown emails
all_unknowns = {}
all_unknowns.update(commit_unknowns)
all_unknowns.update(coauthor_unknowns)
# Filter out AI assistants, bots, and machine accounts
ignored = {h for h in all_contributors if is_ignored(h)}
for h in ignored:
del all_contributors[h]
# Also filter unknowns by email
all_unknowns = {e: n for e, n in all_unknowns.items() if not is_ignored(n, e)}
# ---- Output ----
print()
print(f"=== All Contributors ({len(all_contributors)}) ===")
print()
# Sort by handle, case-insensitive
for handle in sorted(all_contributors.keys(), key=str.lower):
sources = sorted(all_contributors[handle])
source_str = ", ".join(sources)
extra = ""
if handle in salvage_pr_refs:
pr_nums = salvage_pr_refs[handle]
extra = f" (PRs: {', '.join(f'#{n}' for n in pr_nums)})"
print(f" @{handle} [{source_str}]{extra}")
# ---- Unknown emails ----
if all_unknowns:
print()
print(f"=== Unknown Emails ({len(all_unknowns)}) ===")
print("These emails are not in AUTHOR_MAP and should be added:")
print()
for email, name in sorted(all_unknowns.items()):
print(f' "{email}": "{name}",')
# ---- Release file comparison ----
if args.release_file:
print()
print(f"=== Release File Check: {args.release_file} ===")
print()
mentioned, missing = check_release_file(args.release_file, all_contributors.keys())
print(f" Mentioned in release notes: {len(mentioned)}")
print(f" Missing from release notes: {len(missing)}")
if missing:
print()
print(" Contributors NOT mentioned in the release file:")
for handle in sorted(missing, key=str.lower):
sources = sorted(all_contributors[handle])
print(f" @{handle} [{', '.join(sources)}]")
else:
print()
print(" All contributors are mentioned in the release file!")
print()
print("Done.")
if __name__ == "__main__":
main()
+48 -5
View File
@@ -94,6 +94,7 @@ AUTHOR_MAP = {
"vincentcharlebois@gmail.com": "vincentcharlebois",
"aryan@synvoid.com": "aryansingh",
"johnsonblake1@gmail.com": "blakejohnson",
"kennyx102@gmail.com": "bobashopcashier",
"bryan@intertwinesys.com": "bryanyoung",
"christo.mitov@gmail.com": "christomitov",
"hermes@nousresearch.com": "NousResearch",
@@ -315,6 +316,28 @@ def clean_subject(subject: str) -> str:
return cleaned
def parse_coauthors(body: str) -> list:
"""Extract Co-authored-by trailers from a commit message body.
Returns a list of {'name': ..., 'email': ...} dicts.
Filters out AI assistants and bots (Claude, Copilot, Cursor, etc.).
"""
if not body:
return []
# AI/bot emails to ignore in co-author trailers
_ignored_emails = {"noreply@anthropic.com", "noreply@github.com",
"cursoragent@cursor.com", "hermes@nousresearch.com"}
_ignored_names = re.compile(r"^(Claude|Copilot|Cursor Agent|GitHub Actions?|dependabot|renovate)", re.IGNORECASE)
pattern = re.compile(r"Co-authored-by:\s*(.+?)\s*<([^>]+)>", re.IGNORECASE)
results = []
for m in pattern.finditer(body):
name, email = m.group(1).strip(), m.group(2).strip()
if email in _ignored_emails or _ignored_names.match(name):
continue
results.append({"name": name, "email": email})
return results
def get_commits(since_tag=None):
"""Get commits since a tag (or all commits if None)."""
if since_tag:
@@ -322,10 +345,11 @@ def get_commits(since_tag=None):
else:
range_spec = "HEAD"
# Format: hash|author_name|author_email|subject
# Format: hash|author_name|author_email|subject\0body
# Using %x00 (null) as separator between subject and body
log = git(
"log", range_spec,
"--format=%H|%an|%ae|%s",
"--format=%H|%an|%ae|%s%x00%b%x00",
"--no-merges",
)
@@ -333,13 +357,25 @@ def get_commits(since_tag=None):
return []
commits = []
for line in log.split("\n"):
if not line.strip():
# Split on double-null to get each commit entry, since body ends with \0
# and format ends with \0, each record ends with \0\0 between entries
for entry in log.split("\0\0"):
entry = entry.strip()
if not entry:
continue
parts = line.split("|", 3)
# Split on first null to separate "hash|name|email|subject" from "body"
if "\0" in entry:
header, body = entry.split("\0", 1)
body = body.strip()
else:
header = entry
body = ""
parts = header.split("|", 3)
if len(parts) != 4:
continue
sha, name, email, subject = parts
coauthor_info = parse_coauthors(body)
coauthors = [resolve_author(ca["name"], ca["email"]) for ca in coauthor_info]
commits.append({
"sha": sha,
"short_sha": sha[:8],
@@ -348,6 +384,7 @@ def get_commits(since_tag=None):
"subject": subject,
"category": categorize_commit(subject),
"github_author": resolve_author(name, email),
"coauthors": coauthors,
})
return commits
@@ -389,6 +426,9 @@ def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/N
author = commit["github_author"]
if author not in teknium_aliases:
all_authors.add(author)
for coauthor in commit.get("coauthors", []):
if coauthor not in teknium_aliases:
all_authors.add(coauthor)
# Category display order and emoji
category_order = [
@@ -437,6 +477,9 @@ def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/N
author = commit["github_author"]
if author not in teknium_aliases:
author_counts[author] += 1
for coauthor in commit.get("coauthors", []):
if coauthor not in teknium_aliases:
author_counts[coauthor] += 1
sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
+17 -17
View File
@@ -15,9 +15,9 @@
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
"integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1088,9 +1088,9 @@
}
},
"node_modules/file-type": {
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
"integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
@@ -1456,9 +1456,9 @@
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.12.1",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
"integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
"version": "11.12.3",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz",
"integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==",
"funding": [
{
"type": "github",
@@ -1471,11 +1471,11 @@
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@borewit/text-codec": "^0.2.2",
"@tokenizer/token": "^0.3.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"file-type": "^21.3.0",
"file-type": "^21.3.1",
"media-typer": "^1.1.0",
"strtok3": "^10.3.4",
"token-types": "^6.1.2",
@@ -1589,9 +1589,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pino": {
@@ -2002,9 +2002,9 @@
}
},
"node_modules/strtok3": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
@@ -19,7 +19,7 @@ What makes Hermes different:
- **Self-improving through skills** — Hermes learns from experience by saving reusable procedures as skills. When it solves a complex problem, discovers a workflow, or gets corrected, it can persist that knowledge as a skill document that loads into future sessions. Skills accumulate over time, making the agent better at your specific tasks and environment.
- **Persistent memory across sessions** — remembers who you are, your preferences, environment details, and lessons learned. Pluggable memory backends (built-in, Honcho, Mem0, and more) let you choose how memory works.
- **Multi-platform gateway** — the same agent runs on Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, and 8+ other platforms with full tool access, not just chat.
- **Multi-platform gateway** — the same agent runs on Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, and 10+ other platforms with full tool access, not just chat.
- **Provider-agnostic** — swap models and providers mid-workflow without changing anything else. Credential pools rotate across multiple API keys automatically.
- **Profiles** — run multiple independent Hermes instances with isolated configs, sessions, skills, and memory.
- **Extensible** — plugins, MCP servers, custom tools, webhook triggers, cron scheduling, and the full Python ecosystem.
@@ -148,7 +148,7 @@ hermes gateway status Check status
hermes gateway setup Configure platforms
```
Supported platforms: Telegram, Discord, Slack, WhatsApp, Signal, Email, SMS, Matrix, Mattermost, Home Assistant, DingTalk, Feishu, WeCom, API Server, Webhooks, Open WebUI.
Supported platforms: Telegram, Discord, Slack, WhatsApp, Signal, Email, SMS, Matrix, Mattermost, Home Assistant, DingTalk, Feishu, WeCom, BlueBubbles (iMessage), Weixin (WeChat), API Server, Webhooks. Open WebUI connects via the API Server adapter.
Platform docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/
@@ -215,7 +215,7 @@ hermes insights [--days N] Usage analytics
hermes update Update to latest version
hermes pairing list/approve/revoke DM authorization
hermes plugins list/install/remove Plugin management
hermes honcho setup/status Honcho memory integration
hermes honcho setup/status Honcho memory integration (requires honcho plugin)
hermes memory setup/status/off Memory provider config
hermes completion bash|zsh Shell completions
hermes acp ACP server (IDE integration)
@@ -269,6 +269,28 @@ Type these during an interactive chat session.
/plugins List plugins (CLI)
```
### Gateway
```
/approve Approve a pending command (gateway)
/deny Deny a pending command (gateway)
/restart Restart gateway (gateway)
/sethome Set current chat as home channel (gateway)
/update Update Hermes to latest (gateway)
/platforms (/gateway) Show platform connection status (gateway)
```
### Utility
```
/branch (/fork) Branch the current session
/btw Ephemeral side question (doesn't interrupt main task)
/fast Toggle priority/fast processing
/browser Open CDP browser connection
/history Show conversation history (CLI)
/save Save conversation to file (CLI)
/paste Attach clipboard image (CLI)
/image Attach local image file (CLI)
```
### Info
```
/help Show commands
@@ -311,11 +333,11 @@ Edit with `hermes config edit` or `hermes config set section.key value`.
| `terminal` | `backend` (local/docker/ssh/modal), `cwd`, `timeout` (180) |
| `compression` | `enabled`, `threshold` (0.50), `target_ratio` (0.20) |
| `display` | `skin`, `tool_progress`, `show_reasoning`, `show_cost` |
| `stt` | `enabled`, `provider` (local/groq/openai) |
| `tts` | `provider` (edge/elevenlabs/openai/kokoro/fish) |
| `stt` | `enabled`, `provider` (local/groq/openai/mistral) |
| `tts` | `provider` (edge/elevenlabs/openai/minimax/mistral/neutts) |
| `memory` | `memory_enabled`, `user_profile_enabled`, `provider` |
| `security` | `tirith_enabled`, `website_blocklist` |
| `delegation` | `model`, `provider`, `max_iterations` (50) |
| `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` |
| `smart_model_routing` | `enabled`, `cheap_model` |
| `checkpoints` | `enabled`, `max_snapshots` (50) |
@@ -323,7 +345,7 @@ Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/con
### Providers
18 providers supported. Set via `hermes model` or `hermes setup`.
20+ providers supported. Set via `hermes model` or `hermes setup`.
| Provider | Auth | Key env var |
|----------|------|-------------|
@@ -332,16 +354,23 @@ Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/con
| Nous Portal | OAuth | `hermes login --provider nous` |
| OpenAI Codex | OAuth | `hermes login --provider openai-codex` |
| GitHub Copilot | Token | `COPILOT_GITHUB_TOKEN` |
| Google Gemini | API key | `GOOGLE_API_KEY` or `GEMINI_API_KEY` |
| DeepSeek | API key | `DEEPSEEK_API_KEY` |
| xAI / Grok | API key | `XAI_API_KEY` |
| Hugging Face | Token | `HF_TOKEN` |
| Z.AI / GLM | API key | `GLM_API_KEY` |
| MiniMax | API key | `MINIMAX_API_KEY` |
| MiniMax CN | API key | `MINIMAX_CN_API_KEY` |
| Kimi / Moonshot | API key | `KIMI_API_KEY` |
| Alibaba / DashScope | API key | `DASHSCOPE_API_KEY` |
| Xiaomi MiMo | API key | `XIAOMI_API_KEY` |
| Kilo Code | API key | `KILOCODE_API_KEY` |
| AI Gateway (Vercel) | API key | `AI_GATEWAY_API_KEY` |
| OpenCode Zen | API key | `OPENCODE_ZEN_API_KEY` |
| OpenCode Go | API key | `OPENCODE_GO_API_KEY` |
| Qwen OAuth | OAuth | `hermes login --provider qwen-oauth` |
| Custom endpoint | Config | `model.base_url` + `model.api_key` in config.yaml |
Plus: AI Gateway, OpenCode Zen, OpenCode Go, MiniMax CN, GitHub Copilot ACP.
| GitHub Copilot ACP | External | `COPILOT_CLI_PATH` or Copilot CLI |
Full provider docs: https://hermes-agent.nousresearch.com/docs/integrations/providers
@@ -365,6 +394,10 @@ Enable/disable via `hermes tools` (interactive) or `hermes tools enable/disable
| `delegation` | Subagent task delegation |
| `cronjob` | Scheduled task management |
| `clarify` | Ask user clarifying questions |
| `messaging` | Cross-platform message sending |
| `search` | Web search only (subset of `web`) |
| `todo` | In-session task planning and tracking |
| `rl` | Reinforcement learning tools (off by default) |
| `moa` | Mixture of Agents (off by default) |
| `homeassistant` | Smart home control (off by default) |
@@ -382,12 +415,13 @@ Provider priority (auto-detected):
1. **Local faster-whisper** — free, no API key: `pip install faster-whisper`
2. **Groq Whisper** — free tier: set `GROQ_API_KEY`
3. **OpenAI Whisper** — paid: set `VOICE_TOOLS_OPENAI_KEY`
4. **Mistral Voxtral** — set `MISTRAL_API_KEY`
Config:
```yaml
stt:
enabled: true
provider: local # local, groq, openai
provider: local # local, groq, openai, mistral
local:
model: base # tiny, base, small, medium, large-v3
```
@@ -399,8 +433,9 @@ stt:
| Edge TTS | None | Yes (default) |
| ElevenLabs | `ELEVENLABS_API_KEY` | Free tier |
| OpenAI | `VOICE_TOOLS_OPENAI_KEY` | Paid |
| Kokoro (local) | None | Free |
| Fish Audio | `FISH_AUDIO_API_KEY` | Free tier |
| MiniMax | `MINIMAX_API_KEY` | Paid |
| Mistral (Voxtral) | `MISTRAL_API_KEY` | Paid |
| NeuTTS (local) | None (`pip install neutts[all]` + `espeak-ng`) | Free |
Voice commands: `/voice on` (voice-to-voice), `/voice tts` (always voice), `/voice off`.
@@ -492,7 +527,7 @@ terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_14305
### Voice not working
1. Check `stt.enabled: true` in config.yaml
2. Verify provider: `pip install faster-whisper` or set API key
3. Restart gateway: `/restart`
3. In gateway: `/restart`. In CLI: exit and relaunch.
### Tool not available
1. `hermes tools` — check if toolset is enabled for your platform
@@ -503,10 +538,11 @@ terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_14305
1. `hermes doctor` — check config and dependencies
2. `hermes login` — re-authenticate OAuth providers
3. Check `.env` has the right API key
4. **Copilot 403**: `gh auth login` tokens do NOT work for Copilot API. You must use the Copilot-specific OAuth device code flow via `hermes model` → GitHub Copilot.
### Changes not taking effect
- **Tools/skills:** `/reset` starts a new session with updated toolset
- **Config changes:** `/restart` reloads gateway config
- **Config changes:** In gateway: `/restart`. In CLI: exit and relaunch.
- **Code changes:** Restart the CLI or gateway process
### Skills not showing
@@ -520,6 +556,23 @@ Check logs first:
grep -i "failed to send\|error" ~/.hermes/logs/gateway.log | tail -20
```
Common gateway problems:
- **Gateway dies on SSH logout**: Enable linger: `sudo loginctl enable-linger $USER`
- **Gateway dies on WSL2 close**: WSL2 requires `systemd=true` in `/etc/wsl.conf` for systemd services to work. Without it, gateway falls back to `nohup` (dies when session closes).
- **Gateway crash loop**: Reset the failed state: `systemctl --user reset-failed hermes-gateway`
### Platform-specific issues
- **Discord bot silent**: Must enable **Message Content Intent** in Bot → Privileged Gateway Intents.
- **Slack bot only works in DMs**: Must subscribe to `message.channels` event. Without it, the bot ignores public channels.
- **Windows HTTP 400 "No models provided"**: Config file encoding issue (BOM). Ensure `config.yaml` is saved as UTF-8 without BOM.
### Auxiliary models not working
If `auxiliary` tasks (vision, compression, session_search) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider:
```bash
hermes config set auxiliary.vision.provider <your_provider>
hermes config set auxiliary.vision.model <model_name>
```
---
## Where to Find Things
@@ -557,7 +610,7 @@ hermes-agent/
├── toolsets.py # Toolset definitions
├── cli.py # Interactive CLI (HermesCLI)
├── hermes_state.py # SQLite session store
├── agent/ # Prompt builder, compression, display, adapters
├── agent/ # Prompt builder, context compression, memory, model routing, credential pooling, skill dispatch
├── hermes_cli/ # CLI subcommands, config, setup, commands
│ ├── commands.py # Slash command registry (CommandDef)
│ ├── config.py # DEFAULT_CONFIG, env var definitions
@@ -626,7 +679,6 @@ run_conversation():
### Testing
```bash
source venv/bin/activate # or .venv/bin/activate
python -m pytest tests/ -o 'addopts=' -q # Full suite
python -m pytest tests/tools/ -q # Specific area
```
@@ -820,6 +820,24 @@ Every successful ML paper centers on what Neel Nanda calls "the narrative": a sh
**If you cannot state your contribution in one sentence, you don't yet have a paper.**
### The Sources Behind This Guidance
This skill synthesizes writing philosophy from researchers who have published extensively at top venues. The writing philosophy layer was originally compiled by [Orchestra Research](https://github.com/orchestra-research) as the `ml-paper-writing` skill.
| Source | Key Contribution | Link |
|--------|-----------------|------|
| **Neel Nanda** (Google DeepMind) | The Narrative Principle, What/Why/So What framework | [How to Write ML Papers](https://www.alignmentforum.org/posts/eJGptPbbFPZGLpjsp/highly-opinionated-advice-on-how-to-write-ml-papers) |
| **Sebastian Farquhar** (DeepMind) | 5-sentence abstract formula | [How to Write ML Papers](https://sebastianfarquhar.com/on-research/2024/11/04/how_to_write_ml_papers/) |
| **Gopen & Swan** | 7 principles of reader expectations | [Science of Scientific Writing](https://cseweb.ucsd.edu/~swanson/papers/science-of-writing.pdf) |
| **Zachary Lipton** | Word choice, eliminating hedging | [Heuristics for Scientific Writing](https://www.approximatelycorrect.com/2018/01/29/heuristics-technical-scientific-writing-machine-learning-perspective/) |
| **Jacob Steinhardt** (UC Berkeley) | Precision, consistent terminology | [Writing Tips](https://bounded-regret.ghost.io/) |
| **Ethan Perez** (Anthropic) | Micro-level clarity tips | [Easy Paper Writing Tips](https://ethanperez.net/easy-paper-writing-tips/) |
| **Andrej Karpathy** | Single contribution focus | Various lectures |
**For deeper dives into any of these, see:**
- [references/writing-guide.md](references/writing-guide.md) — Full explanations with examples
- [references/sources.md](references/sources.md) — Complete bibliography
### Time Allocation
Spend approximately **equal time** on each of:
@@ -4,6 +4,12 @@ This document lists all authoritative sources used to build this skill, organize
---
## Origin & Attribution
The writing philosophy, citation verification workflow, and conference reference materials in this skill were originally compiled by **[Orchestra Research](https://github.com/orchestra-research)** as the `ml-paper-writing` skill (January 2026), drawing on Neel Nanda's blog post and other researcher guides listed below. The skill was integrated into hermes-agent by teknium (January 2026), then expanded into the current `research-paper-writing` pipeline by SHL0MS (April 2026, PR #4654), which added experiment design, execution monitoring, iterative refinement, and submission phases while preserving the original writing philosophy and reference files.
---
## Writing Philosophy & Guides
### Primary Sources (Must-Read)
-374
View File
@@ -1,374 +0,0 @@
# 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._
+39 -114
View File
@@ -17,7 +17,6 @@ from agent.auxiliary_client import (
call_llm,
async_call_llm,
_read_codex_access_token,
_get_auxiliary_provider,
_get_provider_chain,
_is_payment_error,
_try_payment_fallback,
@@ -32,12 +31,6 @@ def _clean_env(monkeypatch):
"OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY",
"OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL",
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
# Per-task provider/model/direct-endpoint overrides
"AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL",
"AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY",
"AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL",
"AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY",
"CONTEXT_COMPRESSION_PROVIDER", "CONTEXT_COMPRESSION_MODEL",
):
monkeypatch.delenv(key, raising=False)
@@ -568,29 +561,6 @@ class TestGetTextAuxiliaryClient:
call_kwargs = mock_openai.call_args
assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1"
def test_task_direct_endpoint_override(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_API_KEY", "task-key")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client("web_extract")
assert model == "task-model"
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
assert mock_openai.call_args.kwargs["api_key"] == "task-key"
def test_task_direct_endpoint_without_openai_key_uses_placeholder(self, monkeypatch):
"""Local endpoints without an API key should use 'no-key-required' placeholder."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client("web_extract")
assert client is not None
assert model == "task-model"
assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch):
config = {
"model": {
@@ -879,73 +849,9 @@ class TestAuxiliaryPoolAwareness:
class TestGetAuxiliaryProvider:
"""Tests for _get_auxiliary_provider env var resolution."""
def test_no_task_returns_auto(self):
assert _get_auxiliary_provider() == "auto"
assert _get_auxiliary_provider("") == "auto"
def test_auxiliary_prefix_takes_priority(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "openrouter")
assert _get_auxiliary_provider("vision") == "openrouter"
def test_context_prefix_fallback(self, monkeypatch):
monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous")
assert _get_auxiliary_provider("compression") == "nous"
def test_auxiliary_prefix_over_context_prefix(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_COMPRESSION_PROVIDER", "openrouter")
monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous")
assert _get_auxiliary_provider("compression") == "openrouter"
def test_auto_value_treated_as_auto(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "auto")
assert _get_auxiliary_provider("vision") == "auto"
def test_whitespace_stripped(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", " openrouter ")
assert _get_auxiliary_provider("vision") == "openrouter"
def test_case_insensitive(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "OpenRouter")
assert _get_auxiliary_provider("vision") == "openrouter"
def test_main_provider(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "main")
assert _get_auxiliary_provider("web_extract") == "main"
class TestTaskSpecificOverrides:
"""Integration tests for per-task provider routing via get_text_auxiliary_client(task=...)."""
def test_text_with_vision_provider_override(self, monkeypatch):
"""AUXILIARY_VISION_PROVIDER should not affect text tasks."""
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "nous")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
with patch("agent.auxiliary_client.OpenAI"):
client, model = get_text_auxiliary_client() # no task → auto
assert model == "google/gemini-3-flash-preview" # OpenRouter, not Nous
def test_compression_task_reads_context_prefix(self, monkeypatch):
"""Compression task should check CONTEXT_COMPRESSION_PROVIDER env var."""
monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") # would win in auto
with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \
patch("agent.auxiliary_client.OpenAI"):
mock_nous.return_value = {"access_token": "***"}
client, model = get_text_auxiliary_client("compression")
# Config-first: model comes from config.yaml summary_model default,
# but provider is forced to Nous via env var
assert client is not None
def test_web_extract_task_override(self, monkeypatch):
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "openrouter")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
with patch("agent.auxiliary_client.OpenAI"):
client, model = get_text_auxiliary_client("web_extract")
assert model == "google/gemini-3-flash-preview"
def test_task_direct_endpoint_from_config(self, monkeypatch, tmp_path):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
@@ -979,8 +885,6 @@ class TestTaskSpecificOverrides:
"""model:
default: glm-5.1
provider: opencode-go
compression:
summary_provider: auto
"""
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
@@ -1039,24 +943,45 @@ model:
"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"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "config.yaml").write_text(
"""compression:
summary_provider: custom
summary_model: glm-4.7
summary_base_url: https://api.z.ai/api/coding/paas/v4
"""
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Custom endpoints need an API key to build the client
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client("compression")
assert model == "glm-4.7"
assert mock_openai.call_args.kwargs["base_url"] == "https://api.z.ai/api/coding/paas/v4"
def test_resolve_provider_client_supports_copilot_acp_external_process():
fake_client = MagicMock()
with patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.4-mini"), \
patch("agent.auxiliary_client.CodexAuxiliaryClient", MagicMock()), \
patch("agent.copilot_acp_client.CopilotACPClient", return_value=fake_client) as mock_acp, \
patch("hermes_cli.auth.resolve_external_process_provider_credentials", return_value={
"provider": "copilot-acp",
"api_key": "copilot-acp",
"base_url": "acp://copilot",
"command": "/usr/bin/copilot",
"args": ["--acp", "--stdio"],
}):
client, model = resolve_provider_client("copilot-acp")
assert client is fake_client
assert model == "gpt-5.4-mini"
assert mock_acp.call_args.kwargs["api_key"] == "copilot-acp"
assert mock_acp.call_args.kwargs["base_url"] == "acp://copilot"
assert mock_acp.call_args.kwargs["command"] == "/usr/bin/copilot"
assert mock_acp.call_args.kwargs["args"] == ["--acp", "--stdio"]
def test_resolve_provider_client_copilot_acp_requires_explicit_or_configured_model():
with patch("agent.auxiliary_client._read_main_model", return_value=""), \
patch("agent.copilot_acp_client.CopilotACPClient") as mock_acp, \
patch("hermes_cli.auth.resolve_external_process_provider_credentials", return_value={
"provider": "copilot-acp",
"api_key": "copilot-acp",
"base_url": "acp://copilot",
"command": "/usr/bin/copilot",
"args": ["--acp", "--stdio"],
}):
client, model = resolve_provider_client("copilot-acp")
assert client is None
assert model is None
mock_acp.assert_not_called()
class TestAuxiliaryMaxTokensParam:
@@ -273,18 +273,6 @@ class TestDefaultConfigShape:
assert web["provider"] == "auto"
assert web["model"] == ""
def test_compression_provider_default(self):
from hermes_cli.config import DEFAULT_CONFIG
compression = DEFAULT_CONFIG["compression"]
assert "summary_provider" in compression
assert compression["summary_provider"] == "auto"
def test_compression_base_url_default(self):
from hermes_cli.config import DEFAULT_CONFIG
compression = DEFAULT_CONFIG["compression"]
assert "summary_base_url" in compression
assert compression["summary_base_url"] is None
# ── CLI defaults parity ─────────────────────────────────────────────────────
@@ -12,17 +12,6 @@ def _isolate(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
for env_var in (
"AUXILIARY_VISION_PROVIDER",
"AUXILIARY_VISION_MODEL",
"AUXILIARY_VISION_BASE_URL",
"AUXILIARY_VISION_API_KEY",
"CONTEXT_VISION_PROVIDER",
"CONTEXT_VISION_MODEL",
"CONTEXT_VISION_BASE_URL",
"CONTEXT_VISION_API_KEY",
):
monkeypatch.delenv(env_var, raising=False)
# Write a minimal config so load_config doesn't fail
(hermes_home / "config.yaml").write_text("model:\n default: test-model\n")
@@ -69,6 +58,10 @@ class TestNormalizeVisionProvider:
assert _normalize_vision_provider("beans") == "beans"
assert _normalize_vision_provider("deepseek") == "deepseek"
def test_custom_colon_named_provider_preserved(self):
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("custom:beans") == "beans"
def test_codex_alias_still_works(self):
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("codex") == "openai-codex"
@@ -240,3 +233,22 @@ class TestResolveVisionProviderClientModelNormalization:
assert provider == "zai"
assert client is not None
assert model == "glm-5.1"
class TestVisionPathApiMode:
"""Vision path should propagate api_mode to _get_cached_client."""
def test_explicit_provider_passes_api_mode(self, tmp_path):
_write_config(tmp_path, {
"model": {"default": "test-model"},
"auxiliary": {"vision": {"api_mode": "chat_completions"}},
})
with patch("agent.auxiliary_client._get_cached_client") as mock_gcc:
mock_gcc.return_value = (MagicMock(), "test-model")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client(provider="deepseek")
mock_gcc.assert_called_once()
_, kwargs = mock_gcc.call_args
assert kwargs.get("api_mode") == "chat_completions"
+42
View File
@@ -580,6 +580,48 @@ class TestClassifyApiError:
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
# ── vLLM / local inference server error messages ──
def test_vllm_max_model_len_overflow(self):
"""vLLM's 'exceeds the max_model_len' error → context_overflow."""
e = MockAPIError(
"The engine prompt length 1327246 exceeds the max_model_len 131072. "
"Please reduce prompt.",
status_code=400,
)
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
def test_vllm_prompt_length_exceeds(self):
"""vLLM prompt length error → context_overflow."""
e = MockAPIError(
"prompt length 200000 exceeds maximum model length 131072",
status_code=400,
)
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
def test_vllm_input_too_long(self):
"""vLLM 'input is too long' error → context_overflow."""
e = MockAPIError("input is too long for model", status_code=400)
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
def test_ollama_context_length_exceeded(self):
"""Ollama 'context length exceeded' error → context_overflow."""
e = MockAPIError("context length exceeded", status_code=400)
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
def test_llamacpp_slot_context(self):
"""llama.cpp / llama-server 'slot context' error → context_overflow."""
e = MockAPIError(
"slot context: 4096 tokens, prompt 8192 tokens — not enough space",
status_code=400,
)
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
# ── Result metadata ──
def test_provider_and_model_in_result(self):
+28
View File
@@ -308,6 +308,34 @@ class TestMinimaxPreserveDots:
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is False
def test_opencode_zen_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="opencode-zen", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_opencode_zen_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://opencode.ai/zen/v1")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_zai_provider_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="zai", base_url="")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_bigmodel_cn_url_preserves_dots(self):
from types import SimpleNamespace
agent = SimpleNamespace(provider="custom", base_url="https://open.bigmodel.cn/api/paas/v4")
from run_agent import AIAgent
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_normalize_preserves_m25_free_dot(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free"
def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
@@ -70,6 +70,44 @@ class TestQueryLocalContextLengthOllama:
assert result == 32768
def test_ollama_num_ctx_wins_over_model_info(self):
"""When both num_ctx (Modelfile) and model_info (GGUF) are present,
num_ctx wins because it's the *runtime* context Ollama actually
allocates KV cache for. The GGUF model_info.context_length is the
training max using it would let Hermes grow conversations past
the runtime limit and Ollama would silently truncate.
Concrete example: hermes-brain:qwen3-14b-ctx32k is a Modelfile
derived from qwen3:14b with `num_ctx 32768`, but the underlying
GGUF reports `qwen3.context_length: 40960` (training max). If
Hermes used 40960 it would let the conversation grow past 32768
before compressing, and Ollama would truncate the prefix.
"""
from agent.model_metadata import _query_local_context_length
show_resp = self._make_resp(200, {
"model_info": {"qwen3.context_length": 40960},
"parameters": "num_ctx 32768\ntemperature 0.6\n",
})
models_resp = self._make_resp(404, {})
client_mock = MagicMock()
client_mock.__enter__ = lambda s: client_mock
client_mock.__exit__ = MagicMock(return_value=False)
client_mock.post.return_value = show_resp
client_mock.get.return_value = models_resp
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \
patch("httpx.Client", return_value=client_mock):
result = _query_local_context_length(
"hermes-brain:qwen3-14b-ctx32k", "http://100.77.243.5:11434/v1"
)
assert result == 32768, (
f"Expected num_ctx (32768) to win over model_info (40960), got {result}. "
"If Hermes uses the GGUF training max, conversations will silently truncate."
)
def test_ollama_show_404_falls_through(self):
"""When /api/show returns 404, falls through to /v1/models/{model}."""
from agent.model_metadata import _query_local_context_length

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