Compare commits

..

207 Commits

Author SHA1 Message Date
kshitijk4poor ed201cce9c fix(kimi): drop client-side temperature overrides for Kimi/Moonshot models
The Kimi gateway selects the correct temperature server-side based on the
active mode (thinking on → 1.0, thinking off → 0.6).  Client-side clamping
is no longer needed and would conflict if the gateway changes its defaults.

Removed:
- _FIXED_TEMPERATURE_MODELS, _KIMI_INSTANT_MODELS, _KIMI_THINKING_MODELS,
  _KIMI_PUBLIC_API_OVERRIDES maps from auxiliary_client.py
- All Kimi-specific branches in _fixed_temperature_for_model() — the
  function now always returns None (kept for future non-Kimi contracts)

Callers already guard with 'if fixed_temperature is not None:' so the
change is transparent — temperature is simply omitted from API calls,
letting the Kimi gateway use its own defaults.

Updated tests across 5 files to verify temperature is NOT forced.
2026-04-21 00:00:45 +05:30
Ari Lotter 688c9f5b7c Revert "nix: automatic lockfile fixing to keep main building with nix"
This reverts commit 6f079933cb.
2026-04-20 13:58:02 -04:00
Ari Lotter 6f079933cb nix: automatic lockfile fixing to keep main building with nix 2026-04-20 13:53:09 -04:00
brooklyn! ab37132e59 Merge pull request #13105 from NousResearch/bb/tui-elapsed-lastmsg-8541
feat(tui): turn elapsed in FaceTicker + done-in sys line on turn end (#8541)
2026-04-20 11:40:52 -05:00
Brooklyn Nicholson f1f438e7f9 refactor(tui): drop done-in sys line; FaceTicker counter only
The transcript line was noisy. Keep the one thing the issue really needs:
live elapsed next to the busy verb.
2026-04-20 11:40:12 -05:00
Brooklyn Nicholson 2de1aad028 refactor(tui): turn elapsed lives in FaceTicker; emit done-in sys line
Drops `lastUserAt` plumbing and the right-edge idle ticker. Matches the
claude-code / opencode convention: elapsed rides with the busy indicator
(spinner verb), nothing at idle.

- `turnStartedAt` driven by a useEffect on `ui.busy` — stamps on rising
  edge, clears on falling edge. Covers agent turns and !shell alike.
- FaceTicker renders ` · {fmtDuration}` while busy; 1 s clock for the
  counter, existing 2500 ms cycle for face/verb rotation.
- On busy → idle, if the block ran ≥ 1 s, emit a one-shot
  `done in {fmtDuration}` sys line (≡ claude-code's `thought for Ns`).
2026-04-20 11:38:11 -05:00
Austin Pickett 093aec5a4c Merge pull request #13064 from NousResearch/fix/right-click-paste
fix: enable right click to paste
2026-04-20 09:30:17 -07:00
brooklyn! bf5e2e49c2 Merge pull request #13103 from NousResearch/bb/tui-light-mode-11300
fix(tui): theme-driven update-behind banner + auto-detect light terminals (#11300)
2026-04-20 11:29:50 -05:00
Austin Pickett 52f8d5831f chore: kill comments 2026-04-20 12:27:59 -04:00
Brooklyn Nicholson 9910681b85 refactor(tui): move last-msg elapsed from status bar to prompt right-edge
Status bar ticker was too hot in peripheral vision. The moment the elapsed
value matters is when the prompt returns — so surface it there. Dim
`fmtDuration` next to the GoodVibesHeart, idle-only (hidden while busy),
so quick turns and active streaming stay quiet.
2026-04-20 11:23:58 -05:00
Brooklyn Nicholson 1e7de177e8 feat(tui): show time-since-last-user-message alongside session total (#8541)
StatusRule now renders `{sinceLastMsg}/{sinceSession}` (e.g. `12s/3m 45s`)
when a user has submitted in the current session; falls back to the total
alone otherwise. Wires `lastUserAt` through the state/session lifecycle:
- useSubmission stamps `setLastUserAt(Date.now())` on send
- useSessionLifecycle nulls it in reset/resetVisibleHistory
- /branch slash nulls it on fork
2026-04-20 11:17:34 -05:00
Brooklyn Nicholson 6a06973b0d fix(tui): route update-behind banner through theme + auto-detect light terminals (#11300)
- branding.tsx: `color="yellow"` → `t.color.warn` so light-mode users get the
  burnt-orange warn instead of unreadable bright yellow on white bg.
- theme.ts: replace HERMES_TUI_LIGHT regex with `detectLightMode(env)` that also
  sniffs `COLORFGBG` (XFCE Terminal, rxvt, Terminal.app, iTerm2). Bg slot 7 or
  15 → LIGHT_THEME. Explicit HERMES_TUI_LIGHT (on *or* off) still wins.
- tests: cover empty env, explicit on/off, COLORFGBG positions, and off-override.
2026-04-20 11:12:13 -05:00
kshitijk4poor b7e71fb727 fix(tui): fix Linux Ctrl+C regression, remove double clipboard write
- Fix critical regression: on Linux, Ctrl+C could not interrupt/clear/exit
  because isAction(key,'c') shadowed the isCtrl block (both resolve to k.ctrl
  on non-macOS). Restructured: isAction block now falls through to interrupt
  logic on non-macOS when no selection exists.
- Remove double pbcopy: ink's copySelection() already calls setClipboard()
  which handles pbcopy+tmux+OSC52. The extra writeClipboardText call in
  useInputHandlers copySelection() was firing pbcopy a second time.
- Remove allowClipboardHotkeys prop from TextInput — every caller passed
  isMac, and TextInput already imports isMac. Eliminated prop-drilling
  through appLayout, maskedPrompt, and prompts.
- Remove dead code: the isCtrl copy paths (lines 277-288) were unreachable
  on any platform after the isAction block changes.
- Simplify textInput Cmd+C: use writeClipboardText directly without the
  redundant OSC52 fallback (this path is macOS-only where pbcopy works).
2026-04-20 07:14:33 -07:00
kshitijk4poor e388910fe6 fix(tui): make mac copy use pbcopy 2026-04-20 07:14:33 -07:00
kshitijk4poor 1d0b94a1b9 fix(tui): reserve control on macOS 2026-04-20 07:14:33 -07:00
kshitijk4poor 88396698ea fix(tui): enable clipboard hotkeys in mac input fields 2026-04-20 07:14:33 -07:00
kshitijk4poor c3af012a35 fix(tui): restore clipboard hotkeys in clarify mode 2026-04-20 07:14:33 -07:00
kshitijk4poor 8c9fdedaf5 fix(tui): use command shortcuts on macOS
Make the Ink TUI match macOS keyboard expectations: Command handles copy and common editor/session shortcuts, while Control remains reserved for interrupt/cancel flows. Update the visible hotkey help to show platform-appropriate labels.
2026-04-20 07:14:33 -07:00
Austin Pickett 3030a9fcf9 fix: enable right click to paste 2026-04-20 08:47:46 -04:00
Austin Pickett dcd763c284 Merge pull request #10125 from arihantsethia/feat/dashboard-skill-analytics
feat: add skill analytics to the dashboard
2026-04-20 05:25:58 -07:00
Austin Pickett 720e1c65b2 Merge branch 'main' into feat/dashboard-skill-analytics 2026-04-20 05:25:49 -07:00
Mibayy 3273f301b7 fix(stt): map cloud-only model names to valid local size for faster-whisper (#2544)
Cherry-picked from PR #2545 by @Mibayy.

The setup wizard could leave stt.model: "whisper-1" in config.yaml.
When using the local faster-whisper provider, this crashed with
"Invalid model size 'whisper-1'". Voice messages were silently ignored.

_normalize_local_model() now detects cloud-only names (whisper-1,
gpt-4o-transcribe, etc.) and maps them to the default local model
with a warning. Valid local sizes (tiny, base, small, medium, large-v3)
pass through unchanged.

- Renamed _normalize_local_command_model -> _normalize_local_model
  (backward-compat wrapper preserved)
- 6 new tests including integration test
- Added lowercase AUTHOR_MAP alias for @Mibayy

Closes #2544
2026-04-20 05:18:48 -07:00
Ruzzgar 0613f10def fix(gateway): use persisted session origin for shutdown notifications
Prefer session_store origin over _parse_session_key() for shutdown
notifications. Fixes misrouting when chat identifiers contain colons
(e.g. Matrix room IDs like !room123:example.org).

Falls back to session-key parsing when no persisted origin exists.

Co-authored-by: Ruzzgar <ruzzgarcn@gmail.com>
Ref: #12766
2026-04-20 05:15:54 -07:00
Teknium 9725b452a1 fix: extract _repair_tool_call_arguments helper, add tests, bound loop
Follow-up for PR #12252 salvage:
- Extract 75-line inline repair block to _repair_tool_call_arguments()
  module-level helper for testability and readability
- Remove redundant 'import re as _re' (re already imported at line 33)
- Bound the while-True excess-delimiter removal loop to 50 iterations
- Add 17 tests covering all 6 repair stages
- Add sirEven to AUTHOR_MAP in release.py
2026-04-20 05:12:55 -07:00
Severin Bretscher 9eeaaa4f1b fix(agent): repair malformed tool_call arguments before API send
Cherry-picked from PR #12252 by @sirEven.

Models like GLM-5.1 via Ollama can produce malformed tool_call arguments
(truncated JSON, trailing commas, Python None). The existing except
Exception: pass silently passes broken args to the API, which rejects
them with HTTP 400, crashing the session.

Adds a multi-stage repair pipeline at the pre-send normalization point:
1. Empty/whitespace-only → {}
2. Python None literal → {}
3. Strip trailing commas
4. Auto-close unclosed brackets
5. Remove excess closing delimiters
6. Last resort: replace with {} (logged at WARNING)
2026-04-20 05:12:55 -07:00
Sanjays2402 570f8bab8f fix(compression): exclude completion tokens from compression trigger (#12026)
Cherry-picked from PR #12481 by @Sanjays2402.

Reasoning models (GLM-5.1, QwQ, DeepSeek R1) inflate completion_tokens
with internal thinking tokens. The compression trigger summed
prompt_tokens + completion_tokens, causing premature compression at ~42%
actual context usage instead of the configured 50% threshold.

Now uses only prompt_tokens — completion tokens don't consume context
window space for the next API call.

- 3 new regression tests
- Added AUTHOR_MAP entry for @Sanjays2402

Closes #12026
2026-04-20 05:12:10 -07:00
Teknium 42c30985c7 fix: enable plugins in config.yaml for lazy-discovery tests
The opt-in-by-default change (70111eea) requires plugins to be listed
in plugins.enabled. The cherry-picked test fixtures didn't write this
config, so two tests failed on current main.
2026-04-20 05:11:39 -07:00
Stephen Schoettler a5e368ebfb fix: publish plugin slash commands in Telegram menu
- discover plugin commands before building Telegram command menus
- make plugin command and context engine accessors lazy-load plugins
- add regression coverage for Telegram menu and plugin lookup paths
2026-04-20 05:11:39 -07:00
Teknium 34ae13e6ed chore: add jplew to AUTHOR_MAP 2026-04-20 05:10:23 -07:00
JP Lew 9fdfb09aed fix(telegram): cache inbound videos and accept mp4 uploads 2026-04-20 05:10:23 -07:00
Junass1 aebf32229b fix(session_search): restore same-session context when message ids are interleaved
Replaces global id +/- 1 context lookup with CTE-based same-session
neighbor queries. When multiple sessions write concurrently, id adjacency
does not imply session adjacency — the old query missed real neighbors.

Co-authored-by: Junass1 <ysfalweshcan@gmail.com>
2026-04-20 05:10:03 -07:00
PStarH 00192d51f1 fix(install): quote PYTHON_PATH and UV_CMD for paths with spaces on macOS (#10009)
Cherry-picked from PR #10019 by @PStarH.

On macOS, uv stores Python in ~/Library/Application Support/uv/...
which contains a space. Unquoted $PYTHON_PATH and $UV_CMD caused
word-splitting under set -e, silently aborting install.sh.

Quotes all variable expansions in check_python():
- "$PYTHON_PATH" in command invocations
- "$UV_CMD" in uv calls
- Outer quotes on $(...) assignments

Closes #10009
2026-04-20 05:03:14 -07:00
sprmn24 ed76185c15 feat(whatsapp): implement send_voice for audio message delivery
WhatsApp already receives incoming voice messages (audio/ogg via the
bridge) but lacked a send_voice implementation, so TTS and audio
responses fell back to the base class send_image path instead of being
delivered as native audio messages.

Route send_voice through the existing _send_media_to_bridge helper
with media_type='audio', matching the pattern used by send_video and
send_document.
2026-04-20 05:00:30 -07:00
Jason 23b81ab243 fix(cli): send User-Agent in /v1/models probe to pass Cloudflare 1010
Custom Claude proxies fronted by Cloudflare with Browser Integrity Check
enabled (e.g. `packyapi.com`) reject requests with the default
`Python-urllib/*` signature, returning HTTP 403 "error code: 1010".
`probe_api_models` swallowed that in its blanket `except Exception:
continue`, so `validate_requested_model` returned the misleading
"Could not reach the <provider> API to validate `<model>`" error even
though the endpoint is reachable and lists the requested model.

Advertise the probe request as `hermes-cli/<version>` so Cloudflare
treats it as a first-party client. This mirrors the pattern already used
by `agent/gemini_native_adapter.py` and `agent/anthropic_adapter.py`,
which set a descriptive UA for the same reason.

Reproduction (pre-fix):

    python3 -c "
    import urllib.request
    req = urllib.request.Request(
        'https://www.packyapi.com/v1/models',
        headers={'Authorization': 'Bearer sk-...'})
    urllib.request.urlopen(req).read()
    "
    urllib.error.HTTPError: HTTP Error 403: Forbidden
    (body: b'error code: 1010')

Any non-urllib UA (Mozilla, curl, reqwest) returns 200 with the
OpenAI-compatible models listing.

Tested on macOS (Python 3.11). No cross-platform concerns — the change
is a single header addition to an existing `urllib.request.Request`.
2026-04-20 04:56:30 -07:00
houguokun 6cdab70320 fix(batch_runner): mark discarded no-reasoning prompts as completed (#9950)
Cherry-picked from PR #10005 by @houziershi.

Discarded prompts (has_any_reasoning=False) were skipped by `continue`
before being added to completed_in_batch. On --resume they were retried
forever. Now they are added to completed_in_batch before the continue.

- Added AUTHOR_MAP entry for @houziershi

Closes #9950
2026-04-20 04:56:06 -07:00
Teknium 7242afaa5f chore: defer WhatsApp bridge install to first use (#12992)
Remove eager npm install of @whiskeysockets/baileys during
install.sh, install.ps1, and Docker build. The bridge deps are
already installed on-demand by `hermes whatsapp` (Step 4 checks
for node_modules and runs npm install if missing), so there is no
need to pay the cost at initial install for users who never use
WhatsApp.
2026-04-20 04:55:33 -07:00
luyao618 2cdae233e2 fix(config): validate providers config entries — reject non-URL base, accept camelCase aliases (#9332)
Cherry-picked from PR #9359 by @luyao618.

- Accept camelCase aliases (apiKey, baseUrl, apiMode, keyEnv, defaultModel,
  contextLength, rateLimitDelay) with auto-mapping to snake_case + warning
- Validate URL field values with urlparse (scheme + netloc check) — reject
  non-URL strings like 'openai-reverse-proxy' that were silently accepted
- Warn on unknown keys in provider config entries
- Re-order URL field priority: base_url > url > api (was api > url > base_url)
- 12 new tests covering all scenarios

Closes #9332
2026-04-20 04:52:50 -07:00
kshitijk4poor bc2559c44d fix: remove codex spark model support
Drop gpt-5.3-codex-spark from Codex forward-compat synthesis,
provider catalogs, and context metadata now that the API no longer
supports it.
2026-04-20 04:51:44 -07:00
Teknium 70111eea24 feat(plugins): make all plugins opt-in by default
Plugins now require explicit consent to load. Discovery still finds every
plugin — user-installed, bundled, and pip — so they all show up in
`hermes plugins` and `/plugins`, but the loader only instantiates
plugins whose name appears in `plugins.enabled` in config.yaml. This
removes the previous ambient-execution risk where a newly-installed or
bundled plugin could register hooks, tools, and commands on first run
without the user opting in.

The three-state model is now explicit:
  enabled     — in plugins.enabled, loads on next session
  disabled    — in plugins.disabled, never loads (wins over enabled)
  not enabled — discovered but never opted in (default for new installs)

`hermes plugins install <repo>` prompts "Enable 'name' now? [y/N]"
(defaults to no). New `--enable` / `--no-enable` flags skip the prompt
for scripted installs. `hermes plugins enable/disable` manage both lists
so a disabled plugin stays explicitly off even if something later adds
it to enabled.

Config migration (schema v20 → v21): existing user plugins already
installed under ~/.hermes/plugins/ (minus anything in plugins.disabled)
are auto-grandfathered into plugins.enabled so upgrades don't silently
break working setups. Bundled plugins are NOT grandfathered — even
existing users have to opt in explicitly.

Also: HERMES_DISABLE_BUNDLED_PLUGINS env var removed (redundant with
opt-in default), cmd_list now shows bundled + user plugins together with
their three-state status, interactive UI tags bundled entries
[bundled], docs updated across plugins.md and built-in-plugins.md.

Validation: 442 plugin/config tests pass. E2E: fresh install discovers
disk-cleanup but does not load it; `hermes plugins enable disk-cleanup`
activates hooks; migration grandfathers existing user plugins correctly
while leaving bundled plugins off.
2026-04-20 04:46:45 -07:00
Teknium a25c8c6a56 docs(plugins): rename disk-guardian to disk-cleanup + bundled-plugins docs
The original name was cute but non-obvious; disk-cleanup says what it
does. Plugin directory, script, state path, log lines, slash command,
and test module all renamed. No user-visible state exists yet, so no
migration path is needed.

New website page "Built-in Plugins" documents the <repo>/plugins/<name>/
source, how discovery interacts with user/project plugins, the
HERMES_DISABLE_BUNDLED_PLUGINS escape hatch, disk-cleanup's hook
behaviour and deletion rules, and guidance on when a plugin belongs
bundled vs. user-installable. Added to the Features → Core sidebar next
to the main Plugins page, with a cross-reference from plugins.md.
2026-04-20 04:46:45 -07:00
Teknium 1386e277e5 feat(plugins): convert disk-guardian skill into a bundled plugin
Rewires @LVT382009's disk-guardian (PR #12212) from a skill-plus-script
into a plugin that runs entirely via hooks — no agent compliance needed.

- post_tool_call hook auto-tracks files created by write_file / terminal
  / patch when they match test_/tmp_/*.test.* patterns under HERMES_HOME
- on_session_end hook runs cmd_quick cleanup when test files were
  auto-tracked during the turn; stays quiet otherwise
- /disk-guardian slash command keeps status / dry-run / quick / deep /
  track / forget for manual use
- Deterministic cleanup rules, path safety, atomic writes, and audit
  logging preserved from the original contribution
- Protect well-known top-level state dirs (logs/, memories/, sessions/,
  cron/, cache/, etc.) from empty-dir removal so fresh installs don't
  get gutted on first session end

The plugin system gains a bundled-plugin discovery path (<repo>/plugins/
<name>/) alongside user/project/entry-point sources. Memory and
context_engine subdirs are skipped — they keep their own discovery
paths. HERMES_DISABLE_BUNDLED_PLUGINS=1 suppresses the scan; the test
conftest sets it by default so existing plugin tests stay clean.

Co-authored-by: LVT382009 <levantam.98.2324@gmail.com>
2026-04-20 04:46:45 -07:00
Nox 32e6baea31 Update disk_guardian.py 2026-04-20 04:46:45 -07:00
Nox aeecf06dee Update SKILL.md 2026-04-20 04:46:45 -07:00
LVT382009 068b224887 feat(skills): add disk-guardian — autonomous cleanup of Hermes temp files and disk optimization 2026-04-20 04:46:45 -07:00
Teknium 9a57aa2b1f fix(docs): unbreak docs-site-checks — ascii-guard diagram + MDX <1% (#12984)
* fix(docs): unbreak ascii-guard lint on github-pr-review-agent diagram

The intro diagram used 4 side-by-side boxes in one row. ascii-guard can't
parse that layout — it reads the whole thing as one 80-wide outer box and
flags the inner box borders at columns 17/39/60 as 'extra characters after
right border'. Per the ascii-guard-lint-fixing skill, the only fix is to
merge into a single outer box.

Rewritten as one 69-char outer box with four labeled regions separated by
arrows. Same semantic content, lint-clean.

Was blocking docs-site-checks CI as 'action_required' across multiple PRs
(see e.g. run 24661820677).

* fix(docs): backtick-wrap `<1%` to avoid MDX JSX parse error

Docusaurus MDX parses `<1%` as the start of a JSX tag, but `1` isn't a
valid tag-name start so compilation fails with 'Unexpected character `1`
(U+0031) before name'. Wrap in backticks so MDX treats it as literal code
text.

Found by running Build Docusaurus step on the PR that unblocked the
ascii-guard step; full docs tree scanned for other `<digit>` patterns
outside backticks/fences, only this one was unsafe.
2026-04-20 04:29:02 -07:00
Teknium e04a55f37f fix(xurl skill): fix default app pitfall in setup, add agent detection and troubleshooting (#12985)
- Setup step 5: add --app my-app to xurl auth oauth2 so token binds to the correct app
- Setup step 6: add xurl auth default my-app to set the named app as default
- Add pitfall callout explaining the empty 'default' profile trap
- Agent Workflow step 2: detect when default app has no oauth2 tokens
- Add Troubleshooting table with common xurl issues (auth errors, unauthorized_client, enrollment, credits, media upload, dashboard UI bug)
- Bump to v1.1.0

Community report by @0xHarryWeb3
2026-04-20 04:27:57 -07:00
Teknium f683132c1d feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:

  Chat Completions: {type: text|image_url, image_url: {url, detail?}}
  Responses:        {type: input_text|input_image, image_url: <str>, detail?}

The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.

Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.

Changes:

- gateway/platforms/api_server.py
  - _normalize_multimodal_content(): validates + normalizes both Chat and
    Responses content shapes. Returns a plain string for text-only content
    (preserves prompt-cache behavior on existing callers) or a canonical
    [{type:text|image_url,...}] list when images are present.
  - _content_has_visible_payload(): replaces the bare truthy check so a
    user turn with only an image no longer rejects as 'No user message'.
  - _handle_chat_completions and _handle_responses both call the new helper
    for user/assistant content; system messages continue to flatten to text.
  - Codex conversation_history, input[], and inline history paths all share
    the same validator. No duplicated normalizers.

- run_agent.py
  - _summarize_user_message_for_log(): produces a short string summary
    ('[1 image] describe this') from list content for logging, spinner
    previews, and trajectory writes. Fixes AttributeError when list
    user_message hit user_message[:80] + '...' / .replace().
  - _chat_content_to_responses_parts(): module-level helper that converts
    chat-style multimodal content to Responses 'input_text'/'input_image'
    parts. Used in _chat_messages_to_responses_input for Codex routing.
  - _preflight_codex_input_items() now validates and passes through list
    content parts for user/assistant messages instead of stringifying.

- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
  - Unit coverage for _normalize_multimodal_content, including both part
    formats, data URL gating, and all reject paths.
  - Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
    verifying multimodal payloads reach _run_agent intact.
  - 400 coverage for file / input_file / non-image data URL.

- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
  - Regression coverage for the prologue no-crash contract.
  - _chat_content_to_responses_parts round-trip coverage.

- website/docs/user-guide/features/api-server.md
  - Inline image examples for both endpoints.
  - Updated Limitations: files still unsupported, images now supported.

Validated live against openrouter/anthropic/claude-opus-4.6:
  POST /v1/chat/completions  → 200, vision-accurate description
  POST /v1/responses         → 200, same image, clean output_text
  POST /v1/chat/completions [file] → 400 unsupported_content_type
  POST /v1/responses [input_file]  → 400 unsupported_content_type
  POST /v1/responses [non-image data URL] → 400 unsupported_content_type

Closes #5621, #8253, #4046, #6632.

Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
Teknium 3218d58fc5 chore(release): add Swift42 to AUTHOR_MAP 2026-04-20 04:15:04 -07:00
Swift42 b68bc0ad33 Update SKILL.md
Use -q instead of the deprecated/not working -k
2026-04-20 04:15:04 -07:00
Swift42 d41ca86f74 Update duckduckgo.sh 2026-04-20 04:15:04 -07:00
Teknium 04068c5891 feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.

Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
  post_tool_call (which remains observational); first valid str return
  replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
  None return, non-string return, first-match wins, kwargs, hook
  exception fallback, post_tool_call observation invariant, ordering
  vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
  to include the new hook

Design
- transform_tool_result runs AFTER post_tool_call so observers always
  see the original (untransformed) result. This keeps post_tool_call's
  observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
  terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
  drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
Teknium 9f22977fc0 chore(release): add haileymarshall to AUTHOR_MAP 2026-04-20 03:10:19 -07:00
haileymarshall 6b408e131c fix(gateway): pass session_key (not session_id) to active-process check during prune
SessionStore.prune_old_entries was calling
self._has_active_processes_fn(entry.session_id) but the callback wired
up in gateway/run.py is process_registry.has_active_for_session, which
compares against session_key, not session_id. Every other caller in
session.py (_is_session_expired, _should_reset) already passes
session_key, so prune was the only outlier — and because session_id and
session_key live in different namespaces, the guard never fired.

Result in production: sessions with live background processes (queued
cron output, detached agents, long-running Bash) were pruned out of
_entries despite the docstring promising they'd be preserved. When the
process finished and tried to deliver output, the session_key to
session_id mapping was gone and the work was effectively orphaned.

Also update the existing test_prune_skips_entries_with_active_processes,
which was checking the wrong interface (its mock callback took session_id
so it agreed with the buggy implementation). The test now uses a
session_key-based mock, matching the production callback's real contract,
and a new regression guard test pins the behaviour.

Swallowed exceptions inside the prune loop now log at debug level instead
of silently disappearing.
2026-04-20 03:10:19 -07:00
Teknium eba7c869bb fix(steer): drain /steer between individual tool calls, not at batch end (#12959)
Previously, /steer text was only injected after an entire tool batch
completed (_execute_tool_calls_sequential/concurrent returned). If the
batch had a long-running tool (delegate_task, terminal build), the
steer waited for ALL tools to finish before landing — functionally
identical to /queue from the user's perspective.

Now _apply_pending_steer_to_tool_results() is called after EACH
individual tool result is appended to messages, in both the sequential
and concurrent paths. A steer arriving during Tool 1 lands in Tool 1's
result before Tool 2 starts executing.

Also handles leftover steers in the gateway: if a steer arrives during
the final API call (no tool batch to drain into), it's now delivered as
the next user turn instead of being silently dropped.

Fixes user report from Utku.
2026-04-20 03:08:04 -07:00
Teknium 22efc81cd7 fix(sessions): surface compression tips in session lists and resume lookups (#12960)
After a conversation gets compressed, run_agent's _compress_context ends
the parent session and creates a continuation child with the same logical
conversation. Every list affordance in the codebase (list_sessions_rich
with its default include_children=False, plus the CLI/TUI/gateway/ACP
surfaces on top of it) hid those children, and resume-by-ID on the old
root landed on a dead parent with no messages.

Fix: lineage-aware projection on the read path.

- hermes_state.py::get_compression_tip(session_id) — walk the chain
  forward using parent.end_reason='compression' AND
  child.started_at >= parent.ended_at. The timing guard separates
  compression continuations from delegate subagents (which were created
  while the parent was still live) without needing a schema migration.
- hermes_state.py::list_sessions_rich — new project_compression_tips
  flag (default True). For each compressed root in the result, replace
  surfaced fields (id, ended_at, end_reason, message_count,
  tool_call_count, title, last_active, preview, model, system_prompt)
  with the tip's values. Preserve the root's started_at so chronological
  ordering stays stable. Projected rows carry _lineage_root_id for
  downstream consumers. Pass False to get raw roots (admin/debug).
- hermes_cli/main.py::_resolve_session_by_name_or_id — project forward
  after ID/title resolution, so users who remember an old root ID (from
  notes, or from exit summaries produced before the sibling Bug 1 fix)
  land on the live tip.

All downstream callers of list_sessions_rich benefit automatically:
- cli.py _list_recent_sessions (/resume, show_history affordance)
- hermes_cli/main.py sessions list / sessions browse
- tui_gateway session.list picker
- gateway/run.py /resume titled session listing
- tools/session_search_tool.py
- acp_adapter/session.py

Tests: 7 new in TestCompressionChainProjection covering full-chain walks,
delegate-child exclusion, tip surfacing with lineage tracking, raw-root
mode, chronological ordering, and broken-chain graceful fallback.

Verified live: ran a real _compress_context on a live Gemini-backed
session, confirmed the DB split, then verified
- db.list_sessions_rich surfaces tip with _lineage_root_id set
- hermes sessions list shows the tip, not the ended parent
- _resolve_session_by_name_or_id(old_root_id) -> tip_id
- _resolve_last_session -> tip_id

Addresses #10373.
2026-04-20 03:07:51 -07:00
Teknium 0cff992f0a chore(release): add alexzhu0 to AUTHOR_MAP 2026-04-20 03:07:32 -07:00
Alexazhu 64a1368210 fix(tools): keep SSH ControlMaster socket path under macOS 104-byte limit
On macOS, Unix domain socket paths are capped at 104 bytes (sun_path).
SSH appends a 16-byte random suffix to the ControlPath when operating
in ControlMaster mode. With an IPv6 host embedded literally in the
filename and a deeply-nested macOS $TMPDIR like
/var/folders/XX/YYYYYYYYYYYY/T/, the full path reliably exceeds the
limit — every terminal/file-op tool call then fails immediately with
``unix_listener: path "…" too long for Unix domain socket``.

Swap the ``user@host:port.sock`` filename for a sha256-derived 16-char
hex digest. The digest is deterministic for a given (user, host, port)
triple, so ControlMaster reuse across reconnects is preserved, and the
full path fits comfortably under the limit even after SSH's random
suffix. Collision space is 2^64 — effectively unreachable for the
handful of concurrent connections any single Hermes process holds.

Regression tests cover: path length under realistic macOS $TMPDIR with
the IPv6 host from the issue report, determinism for reconnects, and
distinctness across different (user, host, port) triples.

Closes #11840
2026-04-20 03:07:32 -07:00
Teknium 649ef5c8f1 chore(release): add sjz-ks to AUTHOR_MAP 2026-04-20 03:04:06 -07:00
sjz-ks 2081b71c42 feat(tools): add terminal output transform hook 2026-04-20 03:04:06 -07:00
Teknium 9d7aac7ed2 test(gateway): lock in /yolo /verbose bypass and /fast /reasoning catch-all
Four parametrized cases that pin down the running-agent guard behavior:
/yolo and /verbose dispatch mid-run; /fast and /reasoning get the
"can't run mid-turn" catch-all. Prevents the allowlist from silently
drifting in either direction.
2026-04-20 03:03:07 -07:00
elkimek afd08b76c5 fix(gateway): run /yolo and /verbose mid-agent instead of rejecting them
/yolo and /verbose are safe to dispatch while an agent is running:
/yolo can unblock a pending approval prompt, /verbose cycles the
tool-progress display for the ongoing stream. Both modify session
state without needing agent interaction. Previously they fell through
to the running-agent catch-all (PR #12334) and returned the generic
busy message.

/fast and /reasoning stay on the catch-all — their handlers explicitly
say 'takes effect on next message', so nothing is gained by dispatching
them mid-turn.

Salvaged from #10116 (elkimek), scoped down.
2026-04-20 03:03:07 -07:00
Teknium be472138f3 fix(send_message): accept E.164 phone numbers for signal/sms/whatsapp (#12936)
Follow-up to #12704. The SignalAdapter can resolve +E164 numbers to
UUIDs via listContacts, but _parse_target_ref() in the send_message
tool rejected '+' as non-digit and fell through to channel-name
resolution — which fails for contacts without a prior session entry.

Adds an E.164 branch in _parse_target_ref for phone-based platforms
(signal, sms, whatsapp) that preserves the leading '+' so downstream
adapters keep the format they expect. Non-phone platforms are
unaffected.

Reported by @qdrop17 on Discord after pulling #12704.
2026-04-20 03:02:44 -07:00
Teknium 8f4db7bbd5 chore(release): map withapurpose37@gmail.com -> StefanIsMe
Author mapping for the salvaged PR #8191 contributor.
2026-04-20 02:59:57 -07:00
Stefan 654d61ab6f feat(status-bar): per-prompt elapsed stopwatch
Adds a per-prompt elapsed timer to the CLI status bar (live ⏱ while the
turn runs, frozen ⏲ after completion, resets on next prompt).  Fills the
gap left by the KawaiiSpinner — the spinner only shows elapsed time while
actively animating, so it disappears between tool calls and after the
turn finishes.  Status bar is always pinned, so users can glance down
and see how long the current/last prompt has been running.

- New instance vars: _prompt_start_time, _prompt_duration
- Timer starts before agent_thread.start() and freezes once the thread
  has exited (both interrupt and normal-completion paths)
- _format_prompt_elapsed() formats s/m/h/d with seconds visible at all
  scales, trailing zeros hidden on exact boundaries, negative clamp
- Displayed in the wide (>=76 col) status bar as position 7, after the
  session duration timer
- Uses width-1 glyphs (⏱/⏲, no variation selector) to stay aligned in
  monospace terminals
2026-04-20 02:59:57 -07:00
Lumen Radley a2b5627e6d feat(cli): add editor workflow for drafts 2026-04-20 02:53:40 -07:00
Teknium 09ced16ecc fix(cli): apply markdown stripping to background-task and /btw response panels
Follow-up to #12262 — extend final_response_markdown behavior to the other
two final-response Panel render sites (background task completion and /btw
responses) so users see consistent plain-text output everywhere.
2026-04-20 02:53:40 -07:00
Lumen Radley 177e6eb3da feat(cli): strip markdown formatting from final replies 2026-04-20 02:53:40 -07:00
Lumen Radley 22655ed1e6 feat(cli): improve multiline previews 2026-04-20 02:53:40 -07:00
Teknium 2614586306 chore(release): add lumenradley to AUTHOR_MAP 2026-04-20 02:53:40 -07:00
Teknium 93f9db59b2 fix(doctor): update config validation for current auth.py API
Follow-up for #3171 cherry-pick — the contributor's validation block
called get_provider_credentials() which doesn't exist on current main.
Replaces it with get_auth_status() limited to API-key providers in
PROVIDER_REGISTRY so providers without a registry entry (openrouter,
anthropic, custom) don't trigger false 'not authenticated' failures.
Also runs the provider name through resolve_provider() so aliases like
'glm'/'moonshot' validate correctly.

Adds StefanIsMe to AUTHOR_MAP.
2026-04-20 02:41:25 -07:00
Stefan 954dd8a4e0 fix(doctor): catch OpenRouter 402/429 and validate model/provider config
Discovered via real user session where hermes doctor missed two failures:

1. OpenRouter HTTP 402 (credits exhausted) fell through to the generic
   'else' branch — printed yellow but never added to issues, so
   'hermes doctor --fix' couldn't surface it. User had to manually
   find and run 'hermes config set model.provider minimax'.

2. A provider value 'main' (from a stale gateway state or config
   corruption) caused 'Unknown provider main' at runtime. Doctor
   checked that config.yaml existed but never validated that
   model.provider or model.default contained sane values.

Changes:
- OpenRouter health-check now catches 402 (out of credits) and 429
  (rate limited) separately, prints a red X, and adds a fixable
  issue with the exact command to run.
- New config validation after the config.yaml existence check:
  * Validates model.provider against PROVIDER_REGISTRY. Unknown
    provider names fail red with the full valid list.
  * Warns when model.default uses a provider-prefixed name (e.g.
    'anthropic/claude-opus-4') but provider is not openrouter/custom.
  * Warns when model.provider is configured but no API key or
    base_url is set for it.

Both fixes are fully general — they catch classes of errors, not
hardcoded values specific to one user's setup.
2026-04-20 02:41:25 -07:00
Teknium c470a325f7 chore(release): add Linux2010 and elmatadorgh to AUTHOR_MAP 2026-04-20 02:40:20 -07:00
elmatadorgh 1ec4a34dcd test(error_classifier): broaden non-string message type coverage
Adds regression tests for list-typed, int-typed, and None-typed message
fields on top of the dict-typed coverage from #11496. Guards against
other provider quirks beyond the original Pydantic validation case.

Credit to @elmatadorgh (#11264) for the broader type coverage idea.
2026-04-20 02:40:20 -07:00
Linux2010 b869bf206c fix(error_classifier): handle dict-typed message fields without crashing
When API providers return Pydantic-style validation errors where
body['message'] or body['error']['message'] is a dict (e.g.
{"detail": [...]}), the error classifier was crashing with
AttributeError: 'dict' object has no attribute 'lower'.

The 'or ""' fallback only handles None/falsy values. A non-empty
dict is truthy and passes through to .lower(), which fails.

Fix: Wrap all 5 call sites with str() before calling .lower().
This is a no-op for strings and safely converts dicts to their
repr for pattern matching (no false positives on classification
patterns like 'rate limit', 'context length', etc.).

Closes #11233
2026-04-20 02:40:20 -07:00
Teknium acca428c81 chore: add haileymarshall to AUTHOR_MAP 2026-04-20 02:10:53 -07:00
haileymarshall 49282b6e04 fix(gemini): assign unique stream indices to parallel tool calls
The streaming translator in agent/gemini_cloudcode_adapter.py keyed OpenAI
tool-call indices by function name, so when the model emitted multiple
parallel functionCall parts with the same name in a single turn (e.g.
three read_file calls in one response), they all collapsed onto index 0.
Downstream aggregators that key chunks by index would overwrite or drop
all but the first call.

Replace the name-keyed dict with a per-stream counter that persists across
SSE events. Each functionCall part now gets a fresh, unique index,
matching the non-streaming path which already uses enumerate(parts).

Add TestTranslateStreamEvent covering parallel-same-name calls, index
persistence across events, and finish-reason promotion to tool_calls.
2026-04-20 02:10:53 -07:00
Roy-oss1 d990fa52ed docs(feishu): tighten processing reactions section
Change-Id: I9547777b9a09f9cfeb333af9b016e4659a934e24
2026-04-20 02:04:57 -07:00
Roy-oss1 520edd3499 feat(feishu): show processing state via reactions on user messages
Replaces the permanent "OK" receipt reaction with a 3-phase visual
lifecycle:

- Typing animation appears when the agent starts processing.
- Cleared when processing succeeds — the reply message is the signal.
- Replaced with CrossMark when processing fails.
- Cleared when processing is cancelled or interrupted.

When Feishu rejects the reaction-delete call, we keep the Typing in
place and skip adding CrossMark. Showing both at once would leave the
user seeing both "still working" and "done/failed" simultaneously,
which is worse than a stuck Typing.

A FEISHU_REACTIONS env var (default on) disables the whole lifecycle.
User-added reactions with the same emoji still route through to the
agent; only bot-origin reactions are filtered to break the feedback
loop.

Change-Id: I527081da31f0f9d59b451f45de59df4ddab522ba
2026-04-20 02:04:57 -07:00
Ruzzgar 60236862ee fix(agent): fall back when rg is blocked for @folder references 2026-04-20 01:56:41 -07:00
Teknium 8a6aa5882e fix(cli): sync session_id after compression and preserve original end_reason (#12920)
After context compression (manual /compress or auto), run_agent's
_compress_context ends the current session and creates a new continuation
child session, mutating agent.session_id. The classic CLI held its own
self.session_id that never resynced, so /status showed the ended parent,
the exit-summary --resume hint pointed at a closed row, and any later
end_session() call (from /resume <other> or /branch) targeted the wrong
row AND overwrote the parent's 'compression' end_reason.

This only affected the classic prompt_toolkit CLI. The gateway path was
already fixed in PR #1160 (March 2026); --tui and ACP use different
session plumbing and were unaffected.

Changes:
- cli.py::_manual_compress — sync self.session_id from self.agent.session_id
  after _compress_context, clear _pending_title
- cli.py chat loop — same sync post-run_conversation for auto-compression
- cli.py hermes -q single-query mode — same sync so stderr session_id
  output points at the continuation
- hermes_state.py::end_session — guard UPDATE with 'ended_at IS NULL' so
  the first end_reason wins; reopen_session() remains the explicit
  escape hatch for re-ending a closed row

Tests:
- 3 new in tests/cli/test_manual_compress.py (split sync, no-op guard,
  pending_title behavior)
- 2 new in tests/test_hermes_state.py (preserve compression end_reason
  on double-end; reopen-then-re-end still works)

Closes #12483. Credits @steve5636 for the same-day bug report and
@dieutx for PR #3529 which proposed the CLI sync approach.
2026-04-20 01:48:20 -07:00
Ruzzgar f23123e7b4 fix(gateway): prevent scoped lock and resource leaks on connection failure 2026-04-20 01:44:36 -07:00
Teknium a5063ff105 docs(providers): drop stale 'TODO: Phase 4' from get_provider docstring (#12902)
User-defined providers from config.yaml are already resolved via
resolve_provider_full() (which layers resolve_user_provider and
resolve_custom_provider on top of get_provider). Refresh the docstring
to reflect current reality and point future readers at the right entry
point. No behaviour change.

Closes #12309.
2026-04-20 01:41:27 -07:00
teyrebaz33 2d59afd3da fix(docker): pass docker_mount_cwd_to_workspace and docker_forward_env to container_config in file_tools
file_tools._get_file_ops() built a container_config dict for Docker/
Singularity/Modal/Daytona backends but omitted docker_mount_cwd_to_workspace
and docker_forward_env. Both are read by _create_environment() from
container_config, so file tools (read_file, write_file, patch, search)
silently ignored those config values when running in Docker.

Add the two missing keys to match the container_config already built by
terminal_tool.terminal_tool().

Fixes #2672.
2026-04-20 00:58:16 -07:00
Junass1 4c50b4689e fix(gateway): make Telegram DM topic config writes atomic 2026-04-20 00:57:53 -07:00
Teknium 4f24db4258 fix(compression): enforce 64k floor on aux model + auto-correct threshold (#12898)
Context compression silently failed when the auxiliary compression model's
context window was smaller than the main model's compression threshold
(e.g. GLM-4.5-air at 131k paired with a 150k threshold).  The feasibility
check warned but the session kept running and compression attempts errored
out mid-conversation.

Two changes in _check_compression_model_feasibility():

1. Hard floor: if detected aux context < MINIMUM_CONTEXT_LENGTH (64k),
   raise ValueError so the session refuses to start.  Mirrors the existing
   main-model rejection at AIAgent.__init__ line 1600.  A compression model
   below 64k cannot summarise a full threshold-sized window.

2. Auto-correct: when aux context is >= 64k but below the computed
   threshold, lower the live compressor's threshold_tokens to aux_context
   (and update threshold_percent to match so later update_model() calls
   stay in sync).  Warning reworded to say what was done and how to
   persist the fix in config.yaml.

Only ValueError re-raises; other exceptions in the check remain swallowed
as non-fatal.
2026-04-20 00:56:04 -07:00
helix4u 03e3c22e86 fix(config): add stale timeout settings 2026-04-20 00:52:50 -07:00
Teknium 440764e013 chore(release): add salt-555 to AUTHOR_MAP 2026-04-20 00:47:40 -07:00
salt-555 12c8cefbce fix(backup): handle files with pre-1980 timestamps
ZipFile.write() raises ValueError for files with mtime before 1980-01-01
(the ZIP format uses MS-DOS timestamps which can't represent earlier dates).
This crashes the entire backup. Add ValueError to the existing except clause
so these files are skipped and reported in the warnings summary, matching the
existing behavior for PermissionError and OSError.
2026-04-20 00:47:40 -07:00
helix4u afba54364e docs(config): document session_search auxiliary controls 2026-04-20 00:47:39 -07:00
helix4u 6ab78401c9 fix(aux): add session_search extra_body and concurrency controls
Adds auxiliary.<task>.extra_body config passthrough so reasoning-heavy
OpenAI-compatible providers can receive provider-specific request fields
(e.g. enable_thinking: false on GLM) on auxiliary calls, and bounds
session_search summary fan-out with auxiliary.session_search.max_concurrency
(default 3, clamped 1-5) to avoid 429 bursts on small providers.

- agent/auxiliary_client.py: extract _get_auxiliary_task_config helper,
  add _get_task_extra_body, merge config+explicit extra_body with explicit winning
- hermes_cli/config.py: extra_body defaults on all aux tasks +
  session_search.max_concurrency; _config_version 19 -> 20
- tools/session_search_tool.py: semaphore around _summarize_all gather
- tests: coverage in test_auxiliary_client, test_session_search, test_aux_config
- docs: user-guide/configuration.md + fallback-providers.md

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-04-20 00:47:39 -07:00
cresslank 904f20d622 fix(tui): stop empty idle dequeue from triggering ready-state OOM 2026-04-20 00:42:10 -07:00
Teknium edf1aecacd chore(release): add cresslank to AUTHOR_MAP 2026-04-20 00:42:10 -07:00
helix4u e96758291b fix(signal): normalize direct recipients to UUIDs 2026-04-20 00:35:55 -07:00
kshitijk4poor fd5df5fe8e fix(camofox): honor auxiliary vision temperature\n\n- forward auxiliary.vision.temperature in camofox screenshot analysis\n- add regression tests for configured and default behavior 2026-04-20 00:32:09 -07:00
kshitijk4poor 9d88bdaf11 fix(browser): honor auxiliary.vision.temperature for screenshot analysis\n\n- mirror the vision tool's config bridge in browser_vision
- add regression tests for configured and default temperature forwarding
2026-04-20 00:32:09 -07:00
kshitijk4poor 098d554aac test: cover vision config temperature wiring\n\n- add regression tests for auxiliary.vision.temperature and timeout\n- add bugkill3r to AUTHOR_MAP for the salvaged commit 2026-04-20 00:32:09 -07:00
Saurabh 088bf9057f fix: vision tool respects auxiliary.vision.temperature from config (#4661)
The vision tool hardcoded temperature=0.1, ignoring the user's
config.yaml setting. This broke providers like Kimi/Moonshot that
require temperature=1 for vision models. Now reads temperature
from auxiliary.vision.temperature, falling back to 0.1.
2026-04-20 00:32:09 -07:00
kshitijk4poor e485bc60cd test(kimi): cover api.moonshot.cn direct-call regressions\n\n- add run_agent coverage for the Moonshot China endpoint\n- add sync/async trajectory compressor coverage for api.moonshot.cn 2026-04-20 00:32:06 -07:00
kagura-agent 9b60ffc47f fix: include api.moonshot.cn in public API temperature override (#12745)
kimi-k2.5 on api.moonshot.cn/v1 rejects temperature=0.6 with HTTP 400, same
as api.moonshot.ai. The public API check now matches both domains.
2026-04-20 00:32:06 -07:00
helix4u 8155ebd7c4 fix(gemini): sanitize tool schemas for Google providers 2026-04-20 00:26:18 -07:00
Teknium a33e890644 fix(acp): silence 'Background task failed' noise on liveness-probe requests (#12855)
Clients like acp-bridge send periodic bare `ping` JSON-RPC requests as a
liveness probe. The acp router correctly returns JSON-RPC -32601 to the
caller, which those clients already handle as 'agent alive'. But the
supervisor task that ran the request then surfaces the raised RequestError
via `logging.exception('Background task failed', ...)`, dumping a full
traceback to stderr on every probe interval.

Install a logging filter on the stderr handler that suppresses
'Background task failed' records only when the exception is an acp
RequestError(-32601) for one of {ping, health, healthcheck}. Real
method_not_found for any other method, other exception classes, other log
messages, and -32601 logged under a different message all pass through
untouched.

The protocol response is unchanged — the client still receives a standard
-32601 'Method not found' error back. Only the server-side stderr noise is
silenced.

Closes #12529
2026-04-20 00:10:27 -07:00
Teknium e330112aa8 refactor(telegram): use entity-only mention detection
Replaces the word-boundary regex scan with pure MessageEntity-based
detection. Telegram's server emits MENTION entities for real @username
mentions and TEXT_MENTION entities for @FirstName mentions; the text-
scanning fallback was both redundant (entities are always present for
real mentions) and broken (matched raw substrings like email addresses,
URLs, code-block contents, and forwarded literal text).

Entity-only detection:
- Closes bug #12545 ("foo@hermes_bot.example" false positive).
- Also fixes edge cases the regex fix would still miss: @handles inside
  URLs and code blocks, where Telegram does not emit mention entities.

Tests rewritten to exercise realistic Telegram payloads (real mentions
carry entities; substring false positives don't).
2026-04-20 00:10:22 -07:00
Tranquil-Flow 1e18e0503f fix(telegram): use word-boundary matching for bot mention detection (#12545) 2026-04-20 00:10:22 -07:00
JackJin 5157f5427f chore(release): add jackjin1997 qq email to AUTHOR_MAP 2026-04-19 22:46:47 -07:00
JackJin 6c0c625952 fix(gateway): accept finalize kwarg in all platform edit_message overrides
stream_consumer._send_or_edit unconditionally passes finalize= to
adapter.edit_message(), but only DingTalk's override accepted the
kwarg. Streaming on Telegram/Discord/Slack/Matrix/Mattermost/Feishu/
WhatsApp raised TypeError the first time a segment break or final
edit fired.

The REQUIRES_EDIT_FINALIZE capability flag only gates the redundant
final edit (and the identical-text short-circuit), not the kwarg
itself — so adapters that opt out of finalize still receive the
keyword argument and must accept it.

Add *, finalize: bool = False to the 7 non-DingTalk signatures; the
body ignores the arg since those platforms treat edits as stateless
(consistent with the base class contract in base.py).

Add a parametrized signature check over every concrete adapter class
so a future override cannot silently drop the kwarg — existing tests
use MagicMock which swallows any kwarg and cannot catch this.

Fixes #12579
2026-04-19 22:46:47 -07:00
Teknium fc5fda5e38 fix(display): render <missing old_text> in memory previews instead of empty quotes (#12852)
When the model omits old_text on memory replace/remove, the tool preview
rendered as '~memory: ""' / '-memory: ""', which obscured what went wrong.
Render '<missing old_text>' in that case so the failure mode is legible
in the activity feed.

Narrow salvage from #12456 / #12831 — only the display-layer fix, not the
schema/API changes.
2026-04-19 22:45:47 -07:00
Tranquil-Flow 6a228d52f7 fix(webhook): validate HMAC signature before rate limiting (#12544) 2026-04-19 22:45:08 -07:00
Tranquil-Flow 35e7bf6b00 fix(models): validate MiniMax models against static catalog (#12611, #12460, #12399, #12547) 2026-04-19 22:44:47 -07:00
Teknium a4ba0754ed test: drop platform-dependent _resolve_verify test file
The new tests/test_resolve_verify_ssl_context.py used
ssl.get_default_verify_paths().cafile which is None on macOS and
several Linux builds, causing 3 of its 6 tests to fail portably.
The existing tests/hermes_cli/test_auth_nous_provider.py already
covers every _resolve_verify return path with tmp_path + monkeypatched
ssl.create_default_context, which is platform-agnostic.
2026-04-19 22:44:35 -07:00
Tranquil-Flow b53f74a489 fix(auth): use ssl.SSLContext for CA bundle instead of deprecated string path (#12706) 2026-04-19 22:44:35 -07:00
Teknium 65a31ee0d5 fix(anthropic): complete third-party Anthropic-compatible provider support (#12846)
Third-party gateways that speak the native Anthropic protocol (MiniMax,
Zhipu GLM, Alibaba DashScope, Kimi, LiteLLM proxies) now work end-to-end
with the same feature set as direct api.anthropic.com callers.  Synthesizes
eight stale community PRs into one consolidated change.

Five fixes:

- URL detection: consolidate three inline `endswith("/anthropic")`
  checks in runtime_provider.py into the shared _detect_api_mode_for_url
  helper.  Third-party /anthropic endpoints now auto-resolve to
  api_mode=anthropic_messages via one code path instead of three.

- OAuth leak-guard: all five sites that assign `_is_anthropic_oauth`
  (__init__, switch_model, _try_refresh_anthropic_client_credentials,
  _swap_credential, _try_activate_fallback) now gate on
  `provider == "anthropic"` so a stale ANTHROPIC_TOKEN never trips
  Claude-Code identity injection on third-party endpoints.  Previously
  only 2 of 5 sites were guarded.

- Prompt caching: new method `_anthropic_prompt_cache_policy()` returns
  `(should_cache, use_native_layout)` per endpoint.  Replaces three
  inline conditions and the `native_anthropic=(api_mode=='anthropic_messages')`
  call-site flag.  Native Anthropic and third-party Anthropic gateways
  both get the native cache_control layout; OpenRouter gets envelope
  layout.  Layout is persisted in `_primary_runtime` so fallback
  restoration preserves the per-endpoint choice.

- Auxiliary client: `_try_custom_endpoint` honors
  `api_mode=anthropic_messages` and builds `AnthropicAuxiliaryClient`
  instead of silently downgrading to an OpenAI-wire client.  Degrades
  gracefully to OpenAI-wire when the anthropic SDK isn't installed.

- Config hygiene: `_update_config_for_provider` (hermes_cli/auth.py)
  clears stale `api_key`/`api_mode` when switching to a built-in
  provider, so a previous MiniMax custom endpoint's credentials can't
  leak into a later OpenRouter session.

- Truncation continuation: length-continuation and tool-call-truncation
  retry now cover `anthropic_messages` in addition to `chat_completions`
  and `bedrock_converse`.  Reuses the existing `_build_assistant_message`
  path via `normalize_anthropic_response()` so the interim message
  shape is byte-identical to the non-truncated path.

Tests: 6 new files, 42 test cases.  Targeted run + tests/run_agent,
tests/agent, tests/hermes_cli all pass (4554 passed).

Synthesized from (credits preserved via Co-authored-by trailers):
  #7410  @nocoo           — URL detection helper
  #7393  @keyuyuan        — OAuth 5-site guard
  #7367  @n-WN            — OAuth guard (narrower cousin, kept comment)
  #8636  @sgaofen         — caching helper + native-vs-proxy layout split
  #10954 @Only-Code-A     — caching on anthropic_messages+Claude
  #7648  @zhongyueming1121 — aux client anthropic_messages branch
  #6096  @hansnow         — /model switch clears stale api_mode
  #9691  @TroyMitchell911 — anthropic_messages truncation continuation

Closes: #7366, #8294 (third-party Anthropic identity + caching).
Supersedes: #7410, #7367, #7393, #8636, #10954, #7648, #6096, #9691.
Rejects:    #9621 (OpenAI-wire caching with incomplete blocklist — risky),
            #7242 (superseded by #9691, stale branch),
            #8321 (targets smart_model_routing which was removed in #12732).

Co-authored-by: nocoo <nocoo@users.noreply.github.com>
Co-authored-by: Keyu Yuan <leoyuan0099@gmail.com>
Co-authored-by: Zoee <30841158+n-WN@users.noreply.github.com>
Co-authored-by: sgaofen <135070653+sgaofen@users.noreply.github.com>
Co-authored-by: Only-Code-A <bxzt2006@163.com>
Co-authored-by: zhongyueming <mygamez@163.com>
Co-authored-by: Xiaohan Li <hansnow@users.noreply.github.com>
Co-authored-by: Troy Mitchell <i@troy-y.org>
2026-04-19 22:43:09 -07:00
Teknium 491cf25eef test(voice): update existing voice_mode tests for platform-prefixed keys
Follow-up to 40164ba1.

- _handle_voice_channel_join/leave now use event.source.platform instead of
  hardcoded Platform.DISCORD (consistent with other voice handlers).
- Update tests/gateway/test_voice_command.py to use 'platform:chat_id' keys
  matching the new _voice_key() format.
- Add platform isolation regression test for the bug in #12542.
- Drop decorative test_legacy_key_collision_bug (the fix makes the
  collision impossible; the test mutated a single key twice, not a
  real scenario).
- Adapter mocks in _sync_voice_mode_state_to_adapter tests now set
  adapter.platform = Platform.* (required by new isinstance check).
2026-04-19 22:36:00 -07:00
Tranquil-Flow 52a972e927 fix(gateway): namespace voice mode state by platform to prevent cross-platform collision (#12542) 2026-04-19 22:36:00 -07:00
Teknium be3bec55be chore(release): add draix to AUTHOR_MAP 2026-04-19 22:16:37 -07:00
Teknium 1ee3b79f1d fix(gateway): include QQBOT in allowlist-aware unauthorized DM map
Follow-up to #9337: _is_user_authorized maps Platform.QQBOT to
QQ_ALLOWED_USERS, but the new platform_env_map inside
_get_unauthorized_dm_behavior omitted it.  A QQ operator with a strict
user allowlist would therefore still have the gateway send pairing
codes to strangers.

Adds QQBOT to the env map and a regression test.
2026-04-19 22:16:37 -07:00
draix 7282652655 fix(gateway): silence pairing codes when a user allowlist is configured (#9337)
When SIGNAL_ALLOWED_USERS (or any platform-specific or global allowlist)
is set, the gateway was still sending automated pairing-code messages to
every unauthorized sender.  This forced pairing-code spam onto personal
contacts of anyone running Hermes on a primary personal account with a
whitelist, and exposed information about the bot's existence.

Root cause
----------
_get_unauthorized_dm_behavior() fell through to the global default
('pair') even when an explicit allowlist was configured.  An allowlist
signals that the operator has deliberately restricted access; offering
pairing codes to unknown senders contradicts that intent.

Fix
---
Extend _get_unauthorized_dm_behavior() to inspect the active per-platform
and global allowlist env vars.  When any allowlist is set and the operator
has not written an explicit per-platform unauthorized_dm_behavior override,
the method now returns 'ignore' instead of 'pair'.

Resolution order (highest → lowest priority):
1. Explicit per-platform unauthorized_dm_behavior in config — always wins.
2. Explicit global unauthorized_dm_behavior != 'pair' in config — wins.
3. Any platform or global allowlist env var present → 'ignore'.
4. No allowlist, no override → 'pair' (open-gateway default preserved).

This fixes the spam for Signal, Telegram, WhatsApp, Slack, and all other
platforms with per-platform allowlist env vars.

Testing
-------
6 new tests added to tests/gateway/test_unauthorized_dm_behavior.py:

- test_signal_with_allowlist_ignores_unauthorized_dm (primary #9337 case)
- test_telegram_with_allowlist_ignores_unauthorized_dm (same for Telegram)
- test_global_allowlist_ignores_unauthorized_dm (GATEWAY_ALLOWED_USERS)
- test_no_allowlist_still_pairs_by_default (open-gateway regression guard)
- test_explicit_pair_config_overrides_allowlist_default (operator opt-in)
- test_get_unauthorized_dm_behavior_no_allowlist_returns_pair (unit)

All 15 tests in the file pass.

Fixes #9337
2026-04-19 22:16:37 -07:00
Teknium ca3a0bbc54 fix(model-picker): dedup overlapping providers: dict and custom_providers: list entries
When a user's config has the same endpoint in both the providers: dict
(v12+ keyed schema) and custom_providers: list (legacy schema) — which
happens automatically when callers pass the output of
get_compatible_custom_providers() alongside the raw providers dict —
list_authenticated_providers() emitted two picker rows for the same
endpoint: one bare-slug from section 3 and one 'custom:<name>' from
section 4. The slug shapes differed, so seen_slugs dedup never fired,
and users saw the same endpoint twice with identical display labels.

Fix: section 3 records the (display_name, base_url) of each emitted
entry in _section3_emitted_pairs; section 4 skips groups whose
(name, api_url) pair was already emitted. Preserves existing behaviour
for users on either schema alone, and for distinct entries across both.

Test: test_list_authenticated_providers_no_duplicate_labels_across_schemas.
2026-04-19 22:15:49 -07:00
Ben Barclay 519faa6e76 Merge pull request #12821 from NousResearch/fix_broken_docker_test
Fix for broken docker build
2026-04-20 14:38:32 +10:00
Ben 48cb8d20b2 Fix for broken docker build 2026-04-20 14:36:04 +10:00
Teknium 09195be979 docs: repoint tui.md skin reference to features/skins.md
The example-skin.yaml was removed as part of the stale docs cleanup.
Docusaurus features/skins.md covers the same material.

Also update AUTHOR_MAP for balyan.sid@gmail.com → alt-glitch (actual
GitHub login; balyansid returns 404).
2026-04-19 20:39:49 -07:00
alt-glitch bdfb0604ad chore(docs): remove stale documentation files
Remove outdated docs that no longer reflect the current architecture:
ACP setup guide, Honcho integration spec, OpenClaw migration notes,
pricing architecture design, ink-gateway TUI migration plan,
example skin config, and container CLI review fixes.
2026-04-19 20:39:49 -07:00
Brian D. Evans 1cf1016e72 fix(run_agent): preserve dotted Bedrock inference-profile model IDs (#11976)
Bedrock rejects ``global-anthropic-claude-opus-4-7`` with ``HTTP 400:
The provided model identifier is invalid`` because its inference
profile IDs embed structural dots
(``global.anthropic.claude-opus-4-7``) that ``normalize_model_name``
was converting to hyphens.  ``AIAgent._anthropic_preserve_dots`` did
not include ``bedrock`` in its provider allowlist, so every Claude-on-
Bedrock request through the AnthropicBedrock SDK path shipped with
the mangled model ID and failed.

Root cause
----------
``run_agent.py:_anthropic_preserve_dots`` (previously line 6589)
controls whether ``agent.anthropic_adapter.normalize_model_name``
converts dots to hyphens.  The function listed Alibaba, MiniMax,
OpenCode Go/Zen and ZAI but not Bedrock, so when a user set
``provider: bedrock`` with a dotted inference-profile model the flag
returned False and ``normalize_model_name`` mangled every dot in the
ID.  All four call sites in run_agent.py
(``build_anthropic_kwargs`` + three fallback / review / summary paths
at lines 6707, 7343, 8408, 8440) read from this same helper.

The bug shape matches #5211 for opencode-go, which was fixed in commit
f77be22c by extending this same allowlist.

Fix
---
* Add ``"bedrock"`` to the provider allowlist.
* Add ``"bedrock-runtime."`` to the base-URL heuristic as
  defense-in-depth, so a custom-provider-shaped config with
  ``base_url: https://bedrock-runtime.<region>.amazonaws.com`` also
  takes the preserve-dots path even if ``provider`` isn't explicitly
  set to ``"bedrock"``.  This mirrors how the code downstream at
  run_agent.py:759 already treats either signal as "this is Bedrock".

Bedrock model ID shapes covered
-------------------------------
| Shape | Preserved |
| --- | --- |
| ``global.anthropic.claude-opus-4-7`` (reporter's exact ID) | ✓ |
| ``us.anthropic.claude-sonnet-4-5-20250929-v1:0`` | ✓ |
| ``apac.anthropic.claude-haiku-4-5`` | ✓ |
| ``anthropic.claude-3-5-sonnet-20241022-v2:0`` (foundation) | ✓ |
| ``eu.anthropic.claude-3-5-sonnet`` (regional inference profile) | ✓ |

Non-Claude Bedrock models (Nova, Llama, DeepSeek) take the
``bedrock_converse`` / boto3 path which does not call
``normalize_model_name``, so they were never affected by this bug
and remain unaffected by the fix.

Narrow scope — explicitly not changed
-------------------------------------
* ``bedrock_converse`` path (non-Claude Bedrock models) — already
  correct; no ``normalize_model_name`` in that pipeline.
* Provider aliases (``aws``, ``aws-bedrock``, ``amazon``,
  ``amazon-bedrock``) — if a user bypasses the alias-normalization
  pipeline and passes ``provider="aws"`` directly, the base-URL
  heuristic still catches it because Bedrock always uses a
  ``bedrock-runtime.`` endpoint.  Adding the aliases themselves to the
  provider set is cheap but would be scope creep for this fix.
* No other places in ``agent/anthropic_adapter.py`` mangle dots, so
  the fix is confined to ``_anthropic_preserve_dots``.

Regression coverage
-------------------
``tests/agent/test_bedrock_integration.py`` gains three new classes:

* ``TestBedrockPreserveDotsFlag`` (5 tests): flag returns True for
  ``provider="bedrock"`` and for Bedrock runtime URLs (us-east-1 and
  ap-northeast-2 — the reporter's region); returns False for non-
  Bedrock AWS URLs like ``s3.us-east-1.amazonaws.com``; canary that
  Anthropic-native still returns False.
* ``TestBedrockModelNameNormalization`` (5 tests): every documented
  Bedrock model-ID shape survives ``normalize_model_name`` with the
  flag on; inverse canary pins that ``preserve_dots=False`` still
  mangles (so a future refactor can't decouple the flag from its
  effect).
* ``TestBedrockBuildAnthropicKwargsEndToEnd`` (2 tests): integration
  through ``build_anthropic_kwargs`` shows the reporter's exact model
  ID ends up unmangled in the outgoing kwargs.

Three of the new flag tests fail on unpatched ``origin/main`` with
``assert False is True`` (preserve-dots returning False for Bedrock),
confirming the regression is caught.

Validation
----------
``source venv/bin/activate && python -m pytest
tests/agent/test_bedrock_integration.py tests/agent/test_minimax_provider.py
-q`` -> 84 passed (40 new bedrock tests + 44 pre-existing, including
the minimax canaries that pin the pattern this fix mirrors).

CI-aligned broad suite: 12827 passed, 39 skipped, 19 pre-existing
baseline failures (all reproduce on clean ``origin/main``; none in
the touched code path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 20:30:44 -07:00
Teknium 323e827f4a test: remove 8 flaky tests that fail under parallel xdist scheduling (#12784)
These tests all pass in isolation but fail in CI due to test-ordering
pollution on shared xdist workers.  Each has a different root cause:

- tests/tools/test_send_message_tool.py (4 tests): racing session ContextVar
  pollution — get_session_env returns '' instead of 'cli' default when an
  earlier test on the same worker leaves HERMES_SESSION_PLATFORM set.
- tests/tools/test_skills_tool.py (2 tests): KeyError: 'gateway_setup_hint'
  from shared skill state mutation.
- tests/tools/test_tts_mistral.py::test_telegram_produces_ogg_and_voice_compatible:
  pre-existing intermittent failure.
- tests/hermes_cli/test_update_check.py::test_get_update_result_timeout:
  racing a background git-fetch thread that writes a real commits-behind
  value into module-level _update_result before assertion.

All 8 have been failing on main for multiple runs with no clear path to a
safe fix that doesn't require restructuring the tests' isolation story.
Removing is cheaper than chasing — the code paths they cover are
exercised elsewhere (send_message has 73+ other tests, skills_tool has
extensive coverage, TTS has other backend tests, update check has other
tests for check_for_updates proper).

Validation: all 4 files now pass cleanly: 169/169 under CI-parity env.
2026-04-19 19:38:02 -07:00
Teknium b2f8e231dd fix(test): test get_update_result timeout behavior, not result-value identity
My previous attempt (patching check_for_updates) still lost the race:
the background update-check thread captures check_for_updates via
global lookup at call time, but on CI the thread was already past that
point (mid-git-fetch) by the time the test's patch took effect.  The
real fetch returned 4954 commits-behind and wrote that to
banner._update_result before the test's assertion ran.

Fix: test what we actually care about — that get_update_result respects
its timeout parameter — and drop the asserting-on-result-value that
races with legitimate background activity.  The get_update_result
function's job is to return after `timeout` seconds if the event isn't
set.  The value of `_update_result` is incidental to that test.

Validation: tests/hermes_cli/test_update_check.py now 9/9 pass under
CI-parity env, and the test no longer has a correctness dependency on
module-level state that other threads can write.
2026-04-19 19:18:19 -07:00
Teknium ad4680cf74 fix(ci): stub resolve_runtime_provider in cron wake-gate tests + shield update-check timeout test from thread race
Two additional CI failures surfaced when the first PR ran through GHA —
both were pre-existing but blocked merge.

1) tests/cron/test_scheduler.py::TestRunJobWakeGate (3 tests)
   run_job calls resolve_runtime_provider BEFORE constructing AIAgent, so
   patching run_agent.AIAgent alone isn't enough — the resolver raises
   'No inference provider configured' in hermetic CI (no API keys) and
   the test never reaches the mocked AIAgent.  Added autouse fixture
   that stubs resolve_runtime_provider with a fake openrouter runtime.

2) tests/hermes_cli/test_update_check.py::test_get_update_result_timeout
   Observed on CI: assert 4950 is None.  A background update-check
   thread (from an earlier test or hermes_cli.main's own
   prefetch_update_check call) raced a real git-fetch result
   (4950 commits behind origin/main) into banner._update_result during
   this test's wait(0.1).  Wrap the test in patch.object(banner,
   'check_for_updates', return_value=None) so any in-flight thread
   writes None rather than a real value.

Validation:
  Under CI-parity env (env -i, no creds): 6/6 pass
  Broader suite (tests/hermes_cli + cron + gateway + run_agent/streaming
  + toolsets + discord_tool): 6033 passed, pre-existing failures in
  telegram_approval_buttons (3) and internal_event_bypass_pairing (1)
  are unrelated.
2026-04-19 19:18:19 -07:00
Teknium c9b833feb3 fix(ci): unblock test suite + cut ~2s of dead Z.AI probes from every AIAgent
CI on main had 7 failing tests. Five were stale test fixtures; one (agent
cache spillover timeout) was covering up a real perf regression in
AIAgent construction.

The perf bug: every AIAgent.__init__ calls _check_compression_model_feasibility
→ resolve_provider_client('auto') → _resolve_api_key_provider which
iterates PROVIDER_REGISTRY.  When it hits 'zai', it unconditionally calls
resolve_api_key_provider_credentials → _resolve_zai_base_url → probes 8
Z.AI endpoints with an empty Bearer token (all 401s), ~2s of pure latency
per agent, even when the user has never touched Z.AI.  Landed in
9e844160 (PR for credential-pool Z.AI auto-detect) — the short-circuit
when api_key is empty was missing.  _resolve_kimi_base_url had the same
shape; fixed too.

Test fixes:
- tests/gateway/test_voice_command.py: _make_adapter helpers were missing
  self._voice_locks (added in PR #12644, 7 call sites — all updated).
- tests/test_toolsets.py: test_hermes_platforms_share_core_tools asserted
  equality, but hermes-discord has discord_server (DISCORD_BOT_TOKEN-gated,
  discord-only by design).  Switched to subset check.
- tests/run_agent/test_streaming.py: test_tool_name_not_duplicated_when_resent_per_chunk
  missing api_key/base_url — classic pitfall (PR #11619 fixed 16 of
  these; this one slipped through on a later commit).
- tests/tools/test_discord_tool.py: TestConfigAllowlist caplog assertions
  fail in parallel runs because AIAgent(quiet_mode=True) globally sets
  logging.getLogger('tools').setLevel(ERROR) and xdist workers are
  persistent.  Autouse fixture resets the 'tools' and
  'tools.discord_tool' levels per test.

Validation:
  tests/cron + voice + agent_cache + streaming + toolsets + command_guards
  + discord_tool: 550/550 pass
  tests/hermes_cli + tests/gateway: 5713/5713 pass
  AIAgent construction without Z.AI creds: 2.2s → 0.24s (9x)
2026-04-19 19:18:19 -07:00
Teknium 88185e7147 fix(gemini): list Gemini 3 preview models in google-gemini-cli/gemini pickers (#12776)
The google-gemini-cli (Cloud Code Assist) and gemini (native API) model
pickers only offered gemini-2.5-*, so users picking Gemini 3 had to type
a custom model name — usually wrong (e.g. "gemini-3.1-pro"), producing
a 404 from cloudcode-pa.googleapis.com.

Replace the 2.5-* entries with the actual Code Assist / Gemini API
preview IDs: gemini-3.1-pro-preview, gemini-3-pro-preview,
gemini-3-flash-preview (and gemini-3.1-flash-lite-preview on native).
Update the hardcoded fallback in hermes_cli/main.py to match.

Copilot's menu retains gemini-2.5-pro — that catalog is Microsoft's.
2026-04-19 19:13:47 -07:00
Teknium 5d01fc4e6f chore(attribution): add taeng02@icloud.com → taeng0204
Salvaged commit 0c652e9b in this branch is authored by taeng02@icloud.com.
check-attribution CI blocks PRs whose new author emails aren't in
AUTHOR_MAP, so add the mapping to unblock #12680's salvage PR.

GitHub username confirmed via `gh api users/taeng0204` (Taein Lim).
2026-04-19 18:54:35 -07:00
kshitijk4poor 50d6799389 fix: propagate kimi base-url temperature overrides
Follow up salvaged PR #12668 by threading base_url through the
remaining direct-call sites so kimi-k2.5 uses temperature=1.0 on
api.moonshot.ai and keeps 0.6 on api.kimi.com/coding. Add focused
regression tests for run_agent, trajectory_compressor, and
mini_swe_runner.
2026-04-19 18:54:35 -07:00
taeng0204 6f79b8f01d fix(kimi): route temperature override by base_url — kimi-k2.5 needs 1.0 on api.moonshot.ai
Follow-up to #12144.  That PR standardized the kimi-k2.* temperature lock
against the Coding Plan endpoint (api.kimi.com/coding/v1) docs, where
non-thinking models require 0.6.  Verified empirically against Moonshot
(April 2026) that the public chat endpoint (api.moonshot.ai/v1) has a
different contract for kimi-k2.5: it only accepts temperature=1, and rejects
0.6 with:

    HTTP 400 "invalid temperature: only 1 is allowed for this model"

Users hit the public endpoint when KIMI_API_KEY is a legacy sk-* key (the
sk-kimi-* prefix routes to Coding Plan — see hermes_cli/auth.py).  So for
Coding Plan subscribers the fix from #12144 is correct, but for public-API
users it reintroduces the exact 400 reported in #9125.

Reproduction on api.moonshot.ai/v1 + kimi-k2.5:
  temperature=1.0 → 200 OK
  temperature=0.6 → 400 "only 1 is allowed"     ← #12144 default
  temperature=None → 200 OK

Other kimi-k2.* models are unaffected empirically — turbo-preview accepts
0.6 and thinking-turbo accepts 1.0 on both endpoints — so only kimi-k2.5
diverges.

Fix: thread the client's actual base_url through _build_call_kwargs (the
parameter already existed but callers passed config-level resolved_base_url;
for auto-detected routes that was often empty).  _fixed_temperature_for_model
now checks api.moonshot.ai first via an explicit _KIMI_PUBLIC_API_OVERRIDES
map, then falls back to the Coding Plan defaults.  Tests parametrize over
endpoint + model to lock both contracts.

Closes #9125.
2026-04-19 18:54:35 -07:00
Brooklyn Nicholson 0d353ca6a8 fix(tui): bound retained state against idle OOM
Guards four unbounded growth paths reachable at idle — the shape matches
reports of the TUI hitting V8's 2GB heap limit after ~1m of idle with 0
tokens used (Mark-Compact freed ~6MB of 2045MB → pure retention).

- `GatewayClient.logs` + `gateway.stderr` events: 200-line cap is bytes-
  uncapped; a chatty Python child emitting multi-MB lines (traceback,
  dumped config, unsplit JSON) retains everything. Truncate at 4KB/line.
- `GatewayClient.bufferedEvents`: unbounded until `drain()` fires. Cap
  at 2000 so a pre-mount event storm can't pin memory indefinitely.
- `useMainApp` gateway `exit` handler: didn't reset `turnController`, so
  a mid-stream crash left `bufRef`/`reasoningText` alive forever.
- `pasteSnips` count-capped (32) but byte-uncapped. Add a 4MB total cap
  and clear snips in `clearIn` so submitted pastes don't linger.
- `StylePool.transitionCache`: uncapped `Map<number,string>`. Full-clear
  at 32k entries (mirrors `charCache` pattern).
2026-04-19 18:43:40 -07:00
Teknium 424e9f36b0 refactor: remove smart_model_routing feature (#12732)
Smart model routing (auto-routing short/simple turns to a cheap model
across providers) was opt-in and disabled by default.  This removes the
feature wholesale: the routing module, its config keys, docs, tests, and
the orchestration scaffolding it required in cli.py / gateway/run.py /
cron/scheduler.py.

The /fast (Priority Processing / Anthropic fast mode) feature kept its
hooks into _resolve_turn_agent_config — those still build a route dict
and attach request_overrides when the model supports it; the route now
just always uses the session's primary model/provider rather than
running prompts through choose_cheap_model_route() first.

Also removed:
- DEFAULT_CONFIG['smart_model_routing'] block and matching commented-out
  example sections in hermes_cli/config.py and cli-config.yaml.example
- _load_smart_model_routing() / self._smart_model_routing on GatewayRunner
- self._smart_model_routing / self._active_agent_route_signature on
  HermesCLI (signature kept; just no longer initialised through the
  smart-routing pipeline)
- route_label parameter on HermesCLI._init_agent (only set by smart
  routing; never read elsewhere)
- 'Smart Model Routing' section in website/docs/integrations/providers.md
- tip in hermes_cli/tips.py
- entries in hermes_cli/dump.py + hermes_cli/web_server.py
- row in skills/autonomous-ai-agents/hermes-agent/SKILL.md

Tests:
- Deleted tests/agent/test_smart_model_routing.py
- Rewrote tests/agent/test_credential_pool_routing.py to target the
  simplified _resolve_turn_agent_config directly (preserves credential
  pool propagation + 429 rotation coverage)
- Dropped 'cheap model' test from test_cli_provider_resolution.py
- Dropped resolve_turn_route patches from cli + gateway test_fast_command
  — they now exercise the real method end-to-end
- Removed _smart_model_routing stub assignments from gateway/cron test
  helpers

Targeted suites: 74/74 in the directly affected test files;
tests/agent + tests/cron + tests/cli pass except 5 failures that
already exist on main (cron silent-delivery + alias quick-command).
2026-04-19 18:12:55 -07:00
Austin Pickett 5f0a91f31a Merge pull request #12594 from NousResearch/fix/design-system-dashboard
fix: add nous-research/ui package
2026-04-19 18:01:38 -07:00
Teknium 73d0b08351 docs(discord): document that free-response channels skip auto-threading (#12728)
Follow-up to 93fe4b35. The behavior (free-response channels bypass
auto-threading so the channel stays a lightweight inline chat) was
intentional but never documented, causing user confusion ("is this a
bug?" reports).

Adds one line to the behavior table, one paragraph under
discord.free_response_channels, and a cross-reference under
discord.auto_thread.
2026-04-19 16:59:27 -07:00
Teknium d40a828a8b feat(pixel-art): add hardware palettes and video animation (#12725)
Expand the pixel-art skill from 2 presets (arcade, snes) to 14 presets
with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, Apple II,
MS Paint, CRT mono), plus a procedural video overlay pipeline.

Ported from Synero/pixel-art-studio (MIT). Full attribution in
ATTRIBUTION.md.

What's in:
- scripts/palettes.py — 28 named RGB palettes (hardware + artistic)
- scripts/pixel_art.py — 14 presets, named palette support, CLI
- scripts/pixel_art_video.py — 12 animation scenes (stars, rain,
  fireflies, snow, embers, lightning, etc.) → MP4/GIF via ffmpeg
- references/palettes.md — palette catalog
- SKILL.md — clarify-tool workflow (offer style, then optional scene)

What's out (intentional):
- Wu's quantizer (PIL's built-in quantize suffices)
- Sobel edge-aware downsample (scipy dep not worth it)
- Atkinson/Bayer dither (would need numpy reimpl)
- Pollinations text-to-image (Hermes uses image_generate instead)

Video pipeline uses subprocess.run with check=True (replaces os.system)
and tempfile.TemporaryDirectory (replaces manual cleanup).
2026-04-19 16:59:20 -07:00
handsdiff abfc1847b7 fix(terminal): rewrite A && B & to A && { B & } to prevent subshell leak
bash parses `A && B &` with `&&` tighter than `&`, so it forks a subshell
for the compound and backgrounds the subshell. Inside the subshell, B
runs foreground, so the subshell waits for B. When B is a process that
doesn't naturally exit (`python3 -m http.server`, `yes > /dev/null`, a
long-running daemon), the subshell is stuck in `wait4` forever and leaks
as an orphan reparented to init.

Observed in production: agents running `cd X && python3 -m http.server
8000 &>/dev/null & sleep 1 && curl ...` as a "start a local server, then
verify it" one-liner. Outer bash exits cleanly; the subshell never does.
Across ~3 days of use, 8 unique stuck-terminal events and 7 leaked
bash+server pairs accumulated on the fleet, with some sessions appearing
hung from the user's perspective because the subshell's open stdout pipe
kept the terminal tool's drain thread blocked.

This is distinct from the `set +m` fix in 933fbd8f (which addressed
interactive-shell job-control waiting at exit). `set +m` doesn't help
here because `bash -c` is non-interactive and job control is already
off; the problem is the subshell's own internal wait for its foreground
B, not the outer shell's job-tracking.

The fix: walk the command shell-aware (respecting quotes, parens, brace
groups, `&>`/`>&` redirects), find `A && B &` / `A || B &` at depth 0
and rewrite the tail to `A && { B & }`. Brace groups don't fork a
subshell — they run in the current shell. `B &` inside the group is a
simple background (no subshell wait). The outer `&` is absorbed into
the group, so the compound no longer needs an explicit subshell.

`&&` error-propagation is preserved exactly: if A fails, `&&`
short-circuits and B never runs.

- Skips quoted strings, comment lines, and `(…)` subshells
- Handles `&>/dev/null`, `2>&1`, `>&2` without mistaking them for `&`
- Resets chain state at `;`, `|`, and newlines
- Tracks brace depth so already-rewritten output is idempotent
- Walks using the existing `_read_shell_token` tokenizer, matching the
  pattern of `_rewrite_real_sudo_invocations`

Called once from `BaseEnvironment.execute` right after
`_prepare_command`, so it runs for every backend (local, ssh, docker,
modal, etc.) with no per-backend plumbing.

34 new tests covering rewrite cases, preservation cases, redirect
edge-cases, quoting/parens/backticks, idempotency, and empty/edge
inputs. End-to-end verified on a test VM: the exact vela-incident
command now returns in ~1.3s with no leaked bash, only the intentional
backgrounded server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:53:11 -07:00
Teknium af53039dbc chore(release): add etherman-os and mark-ramsell to AUTHOR_MAP 2026-04-19 16:47:20 -07:00
etherman-os d50a9b20d2 terminal: steer long-lived server commands to background mode 2026-04-19 16:47:20 -07:00
Teknium a3a4932405 fix(mcp-oauth): bidirectional auth_flow bridge + absolute expires_at (salvage #12025) (#12717)
* [verified] fix(mcp-oauth): bridge httpx auth_flow bidirectional generator

HermesMCPOAuthProvider.async_auth_flow wrapped the SDK's auth_flow with
'async for item in super().async_auth_flow(request): yield item', which
discards httpx's .asend(response) values and resumes the inner generator
with None. This broke every OAuth MCP server on the first HTTP response
with 'NoneType' object has no attribute 'status_code' crashing at
mcp/client/auth/oauth2.py:505.

Replace with a manual bridge that forwards .asend() values into the
inner generator, preserving httpx's bidirectional auth_flow contract.

Add tests/tools/test_mcp_oauth_bidirectional.py with two regression
tests that drive the flow through real .asend() round-trips. These
catch the bug at the unit level; prior tests only exercised
_initialize() and disk-watching, never the full generator protocol.

Verified against BetterStack MCP:
  Before: 'Connection failed (11564ms): NoneType...' after 3 retries
  After:  'Connected (2416ms); Tools discovered: 83'

Regression from #11383.

* [verified] fix(mcp-oauth): seed token_expiry_time + pre-flight AS discovery on cold-load

PR #11383's consolidation fixed external-refresh reloading and 401 dedup
but left two latent bugs that surfaced on BetterStack and any other OAuth
MCP with a split-origin authorization server:

1. HermesTokenStorage persisted only a relative 'expires_in', which is
   meaningless after a process restart. The MCP SDK's OAuthContext
   does NOT seed token_expiry_time in _initialize, so is_token_valid()
   returned True for any reloaded token regardless of age. Expired
   tokens shipped to servers, and app-level auth failures (e.g.
   BetterStack's 'No teams found. Please check your authentication.')
   were invisible to the transport-layer 401 handler.

2. Even once preemptive refresh did fire, the SDK's _refresh_token
   falls back to {server_url}/token when oauth_metadata isn't cached.
   For providers whose AS is at a different origin (BetterStack:
   mcp.betterstack.com for MCP, betterstack.com/oauth/token for the
   token endpoint), that fallback 404s and drops into full browser
   re-auth on every process restart.

Fix set:

- HermesTokenStorage.set_tokens persists an absolute wall-clock
  expires_at alongside the SDK's OAuthToken JSON (time.time() + TTL
  at write time).
- HermesTokenStorage.get_tokens reconstructs expires_in from
  max(expires_at - now, 0), clamping expired tokens to zero TTL.
  Legacy files without expires_at fall back to file-mtime as a
  best-effort wall-clock proxy, self-healing on the next set_tokens.
- HermesMCPOAuthProvider._initialize calls super(), then
  update_token_expiry on the reloaded tokens so token_expiry_time
  reflects actual remaining TTL. If tokens are loaded but
  oauth_metadata is missing, pre-flight PRM + ASM discovery runs
  via httpx.AsyncClient using the MCP SDK's own URL builders and
  response handlers (build_protected_resource_metadata_discovery_urls,
  handle_auth_metadata_response, etc.) so the SDK sees the correct
  token_endpoint before the first refresh attempt. Pre-flight is
  skipped when there are no stored tokens to keep fresh-install
  paths zero-cost.

Test coverage (tests/tools/test_mcp_oauth_cold_load_expiry.py):
- set_tokens persists absolute expires_at
- set_tokens skips expires_at when token has no expires_in
- get_tokens round-trips expires_at -> remaining expires_in
- expired tokens reload with expires_in=0
- legacy files without expires_at fall back to mtime proxy
- _initialize seeds token_expiry_time from stored tokens
- _initialize flags expired-on-disk tokens as is_token_valid=False
- _initialize pre-flights PRM + ASM discovery with mock transport
- _initialize skips pre-flight when no tokens are stored

Verified against BetterStack MCP:
  hermes mcp test betterstack -> Connected (2508ms), 83 tools
  mcp_betterstack_telemetry_list_teams_tool -> real team data, not
    'No teams found. Please check your authentication.'

Reference: mcp-oauth-token-diagnosis skill, Fix A.

* chore: map hermes@noushq.ai to benbarclay in AUTHOR_MAP

Needed for CI attribution check on cherry-picked commits from PR #12025.

---------

Co-authored-by: Hermes Agent <hermes@noushq.ai>
2026-04-19 16:31:07 -07:00
Teknium a47f5d3ea2 ci: bump test-job timeout from 10m to 20m (#12718)
Recent main runs have been hitting the 10-minute cap repeatedly — the
full non-integration suite no longer fits in that window on
ubuntu-latest. Cancelled runs leave main without a green signal, which
masks real regressions.

Bumps only the test job. The e2e job still finishes in ~25s, so its
10-minute cap stays as-is.
2026-04-19 16:28:13 -07:00
Teknium 19db7fa3d1 ci(security): narrow supply-chain-audit to high-signal patterns only
PR #12681 removed the audit entirely because it fired on nearly every PR
(Dockerfile edits, dependency bumps, Actions version strings, plain
base64 usage, etc.) — reviewers were ignoring it like cancer warnings.

Restore it with aggressive scope reduction:

Kept (real attack signatures):
  - .pth file additions (litellm-attack mechanism)
  - base64 decode + exec/eval on the same line
  - subprocess with base64/hex/chr-encoded command argument
  - install-hook files (setup.py, sitecustomize.py, usercustomize.py,
    __init__.pth)

Removed (low-signal noise that fired constantly):
  - plain base64 encode/decode
  - plain exec/eval
  - outbound requests.post / httpx.post / urllib
  - CI/CD workflow file edits
  - Dockerfile / compose edits
  - pyproject.toml / requirements.txt edits
  - GitHub Actions version-tag unpinning
  - marshal / pickle / compile usage

Also gates the workflow itself on path filters so it only runs on PRs
touching Python or install-hook files — no more firing on docs/CI PRs.

The workflow still fails the check and posts a PR comment on
critical findings, but by design those findings are now rare and
worth inspecting when they occur.
2026-04-19 16:25:21 -07:00
alt-glitch 2f67ef92eb ci: add path filters to Docker and test workflows, remove supply chain audit
- Docker build only triggers on main push (code/config changes) and
  releases, no longer on every PR
- Tests skip markdown-only and docs-only changes
- Remove supply-chain-audit workflow
2026-04-19 16:25:21 -07:00
Austin Pickett c1949e844b fix: imports 2026-04-19 19:22:07 -04:00
Teknium ddd28329ff fix(tui): /model picker surfaces curated list, matching classic CLI (#12671)
model.options unconditionally overwrote each provider's curated model
list with provider_model_ids() (live /models catalog), so TUI users
saw non-agentic models that classic CLI /model and `hermes model`
filter out via the curated _PROVIDER_MODELS source.

On Nous specifically the live endpoint returns ~380 IDs including
TTS, embeddings, rerankers, and image/video generators — the TUI
picker showed all of them. Classic CLI picker showed the curated
30-model list.

Drop the overwrite. list_authenticated_providers() already populates
provider['models'] with the curated list (same source as classic CLI
at cli.py:4792), sliced to max_models=50. Honor that.

Added regression test that fails if the handler ever re-introduces
a provider_model_ids() call over the curated list.
2026-04-19 16:15:22 -07:00
Austin Pickett 823b6d08ed fix: imports 2026-04-19 18:52:04 -04:00
kshitijk4poor d393104bad fix(gemini): tighten native routing and streaming replay
- only use the native adapter for the canonical Gemini native endpoint
- keep custom and /openai base URLs on the OpenAI-compatible path
- preserve Hermes keepalive transport injection for native Gemini clients
- stabilize streaming tool-call replay across repeated SSE events
- add follow-up tests for base_url precedence, async streaming, and duplicate tool-call chunks
2026-04-19 12:40:08 -07:00
kshitijk4poor 3dea497b20 feat(providers): route gemini through the native AI Studio API
- add a native Gemini adapter over generateContent/streamGenerateContent
- switch the built-in gemini provider off the OpenAI-compatible endpoint
- preserve thought signatures and native functionResponse replay
- route auxiliary Gemini clients through the same adapter
- add focused unit coverage plus native-provider integration checks
2026-04-19 12:40:08 -07:00
Teknium aa5bd09232 fix(tests): unstick CI — sweep stale tests from recent merges (#12670)
One source fix (web_server category merge) + five test updates that
didn't travel with their feature PRs. All 13 failures on the 04-19
CI run on main are now accounted for (5 already self-healed on main;
8 fixed here).

Changes
- web_server.py: add code_execution → agent to _CATEGORY_MERGE (new
  singleton section from #11971 broke no-single-field-category invariant).
- test_browser_camofox_state: bump hardcoded _config_version 18 → 19
  (also from #11971).
- test_registry: add browser_cdp_tool (#12369) and discord_tool (#4753)
  to the expected built-in tool set.
- test_run_agent::test_tool_call_accumulation: rewrite fragment chunks
  — #0f778f77 switched streaming name-accumulation from += to = to
  fix MiniMax/NIM duplication; the test still encoded the old
  fragment-per-chunk premise.
- test_concurrent_interrupt::_Stub: no-op
  _apply_pending_steer_to_tool_results — #12116 added this call after
  concurrent tool batches; the hand-rolled stub was missing it.
- test_codex_cli_model_picker: drop the two obsolete tests that
  asserted auto-import from ~/.codex/auth.json into the Hermes auth
  store. #12360 explicitly removed that behavior (refresh-token reuse
  races with Codex CLI / VS Code); adoption is now explicit via
  `hermes auth openai-codex`. Remaining 3 tests in the file (normal
  path, Claude Code fallback, negative case) still cover the picker.

Validation
- scripts/run_tests.sh across all 6 affected files + surrounding tests
  (54 tests total) all green locally.
2026-04-19 12:39:58 -07:00
Teknium d2c2e34469 fix(patch): catch silent persistence failures and escape-drift in tool-call transport (#12669)
Two hardening layers in the patch tool, triggered by a real silent failure
in the previous session:

(1) Post-write verification in patch_replace — after write_file succeeds,
re-read the file and confirm the bytes on disk match the intended write.
If not, return an error instead of the current success-with-diff. Catches
silent persistence failures from any cause (backend FS oddities, stdin
pipe truncation, concurrent task races, mount drift).

(2) Escape-drift guard in fuzzy_find_and_replace — when a non-exact
strategy matches and both old_string and new_string contain literal
\' or \" sequences but the matched file region does not, reject the
patch with a clear error pointing at the likely cause (tool-call
serialization adding a spurious backslash around apostrophes/quotes).
Exact matches bypass the guard, and legitimate edits that add or
preserve escape sequences in files that already have them still work.

Why: in a prior tool call, old_string was sent with \' where the file
has ' (tool-call transport drift). The fuzzy matcher's block_anchor
strategy matched anyway and produced a diff the tool reported as
successful — but the file was never modified on disk. The agent moved
on believing the edit landed when it hadn't.

Tests: added TestPatchReplacePostWriteVerification (3 cases) and
TestEscapeDriftGuard (6 cases). All pass, existing fuzzy match and
file_operations tests unaffected.
2026-04-19 12:27:34 -07:00
Austin Pickett 60fd4b7d16 fix: use grid/cell components 2026-04-19 15:21:57 -04:00
Teknium db60c98276 docs(memory): steer agents to save declarative facts, not instructions (#12665)
Imperative memory entries ('Always respond concisely', 'Run tests with
pytest -n 4') get re-read as directives in future sessions, causing
repeated work or overriding the user's current request. Add a short
phrasing guideline to MEMORY_GUIDANCE so the model writes declarative
facts instead ('User prefers concise responses', 'Project uses pytest
with xdist').

Credit: observation from @Mariandipietra on X.
2026-04-19 12:00:53 -07:00
Teknium cca3278079 fix(codex): pin correct Cloudflare headers and extend to auxiliary client
The cherry-picked salvage (admin28980's commit) added codex headers only on the
primary chat client path, with two inaccuracies:

  - originator was 'hermes-agent' — Cloudflare whitelists codex_cli_rs,
    codex_vscode, codex_sdk_ts, and Codex* prefixes. 'hermes-agent' isn't on
    the list, so the header had no mitigating effect on the 403 (the
    account-id header alone may have been carrying the fix).
  - account-id header was 'ChatGPT-Account-Id' — upstream codex-rs auth.rs
    uses canonical 'ChatGPT-Account-ID' (PascalCase, trailing -ID).

Also, the auxiliary client (_try_codex + resolve_provider_client raw_codex
branch) constructs OpenAI clients against the same chatgpt.com endpoint with
no default headers at all — so compression, title generation, vision, session
search, and web_extract all still 403 from VPS IPs.

Consolidate the header set into _codex_cloudflare_headers() in
agent/auxiliary_client.py (natural home next to _read_codex_access_token and
the existing JWT decode logic) and call it from all four insertion points:

  - run_agent.py: AIAgent.__init__ (initial construction)
  - run_agent.py: _apply_client_headers_for_base_url (credential rotation)
  - agent/auxiliary_client.py: _try_codex (aux client)
  - agent/auxiliary_client.py: resolve_provider_client raw_codex branch

Net: -36/+55 lines, -25 lines of duplicated inline JWT decode replaced by a
single helper. User-Agent switched to 'codex_cli_rs/0.0.0 (Hermes Agent)' to
match the codex-rs shape while keeping product attribution.

Tests in tests/agent/test_codex_cloudflare_headers.py cover:
  - originator value, User-Agent shape, canonical header casing
  - account-ID extraction from a real JWT fixture
  - graceful handling of malformed / non-string / claim-missing tokens
  - wiring at all four insertion points (primary init, rotation, both aux paths)
  - non-chatgpt base URLs (openrouter) do NOT get codex headers
  - switching away from chatgpt.com drops the headers
2026-04-19 11:59:25 -07:00
admin28980 4d0846b640 Fix Cloudflare 403s for openai-codex provider on server IPs
Add ChatGPT-Account-Id and originator headers when using chatgpt.com
backend-api endpoint. Matches official codex-rs CLI behavior to prevent
Cloudflare JavaScript challenges on non-residential IPs (VPS, Mac Mini,
always-on servers).

Applied in AIAgent.__init__ and _update_base_url_headers to cover both
initial setup and credential rotation paths.
2026-04-19 11:59:25 -07:00
Teknium 91eea7544f refactor(creative): promote pixel-art from optional to built-in skills 2026-04-19 11:57:51 -07:00
Teknium 13febe60ca chore(release): add dodo-reach to AUTHOR_MAP 2026-04-19 11:57:51 -07:00
Teknium bbc8499e8c refactor(creative): consolidate pixel-art skills into single preset-based skill
Merges pixel-art-arcade and pixel-art-snes into one pixel-art skill with
named presets (arcade, snes) + parametric overrides. The underlying
pipeline was already identical across both variants — only palette size,
block size, and enhancement strength differed. A single preset-based
function is easier to discover, maintain, and extend (adding a new era
like gameboy or nes is just another preset dict).

Contributor authorship preserved on original additive commit.
2026-04-19 11:57:51 -07:00
dodo-reach 06845b6a03 feat(creative): add pixel-art-arcade and pixel-art-snes skills 2026-04-19 11:57:51 -07:00
Teknium cad3f8a37f docs(site): disable highlightSearchTermsOnTargetPage to keep URLs clean (#12661)
The @easyops-cn/docusaurus-search-local option appends ?_highlight=<term>
query params to links from the search bar. Docusaurus puts the query string
before the #anchor, producing URLs like

    /docs/foo?_highlight=bar#section

which look broken when copy-pasted. Turn the option off — Ctrl+F on the
landing page covers the same use case without polluting shareable links.
2026-04-19 11:56:34 -07:00
Teknium ef73367fc5 feat: add Discord server introspection and management tool (#4753)
* feat: add Discord server introspection and management tool

Add a discord_server tool that gives the agent the ability to interact
with Discord servers when running on the Discord gateway. Uses Discord
REST API directly with the bot token — no dependency on the gateway
adapter's discord.py client.

The tool is only included in the hermes-discord toolset (zero cost for
users on other platforms) and gated on DISCORD_BOT_TOKEN via check_fn.

Actions (14):
- Introspection: list_guilds, server_info, list_channels, channel_info,
  list_roles, member_info, search_members
- Messages: fetch_messages, list_pins, pin_message, unpin_message
- Management: create_thread, add_role, remove_role

This addresses a gap where users on Discord could not ask Hermes to
review server structure, channels, roles, or members — a task competing
agents (OpenClaw) handle out of the box.

Files changed:
- tools/discord_tool.py (new): Tool implementation + registration
- model_tools.py: Add to discovery list
- toolsets.py: Add to hermes-discord toolset only
- tests/tools/test_discord_tool.py (new): 43 tests covering all actions,
  validation, error handling, registration, and toolset scoping

* feat(discord): intent-aware schema filtering + config allowlist + schema cleanup

- _detect_capabilities() hits GET /applications/@me once per process
  to read GUILD_MEMBERS / MESSAGE_CONTENT privileged intent bits.
- Schema is rebuilt per-session in model_tools.get_tool_definitions:
  hides search_members / member_info when GUILD_MEMBERS intent is off,
  annotates fetch_messages description when MESSAGE_CONTENT is off.
- New config key discord.server_actions (comma-separated or YAML list)
  lets users restrict which actions the agent can call, intersected
  with intent availability. Unknown names are warned and dropped.
- Defense-in-depth: runtime handler re-checks the allowlist so a stale
  cached schema cannot bypass a tightened config.
- Schema description rewritten as an action-first manifest (signature
  per action) instead of per-parameter 'required for X, Y, Z' cross-refs.
  ~25% shorter; model can see each action's required params at a glance.
- Added bounds: limit gets minimum=1 maximum=100, auto_archive_duration
  becomes an enum of the 4 valid Discord values.
- 403 enrichment: runtime 403 errors are mapped to actionable guidance
  (which permission is missing and what to do about it) instead of the
  raw Discord error body.
- 36 new tests: capability detection with caching and force refresh,
  config allowlist parsing (string/list/invalid/unknown), intent+allowlist
  intersection, dynamic schema build, runtime allowlist enforcement,
  403 enrichment, and model_tools integration wiring.
2026-04-19 11:52:19 -07:00
Teknium d48d6fadff test(run_agent): pin proxy-env forwarding through keepalive transport
Adds a regression guard for the #11277 → proxy-bypass regression fixed in
42b394c3. With HTTPS_PROXY / HTTP_PROXY / ALL_PROXY set, the custom httpx
transport used for TCP keepalives must still route requests through an
HTTPProxy pool; without proxy env, no HTTPProxy mount should exist.

Also maps zrc <zhurongcheng@rcrai.com> → heykb in scripts/release.py
AUTHOR_MAP so the salvage PR passes the author-attribution CI check.
2026-04-19 11:44:43 -07:00
zrc 023208b17a fix(agent): respect HTTP_PROXY/HTTPS_PROXY when using custom httpx transport
When creating httpx.Client with a custom transport for TCP keepalive,
proxy environment variables (HTTP_PROXY, HTTPS_PROXY) were ignored because
httpx only auto-reads them when transport=None.

Add _get_proxy_from_env() to explicitly read proxy settings and pass them
to httpx.Client, ensuring providers like kimi-coding-cn work correctly
when behind a proxy.

Fixes connection errors when HTTP_PROXY/HTTPS_PROXY are set.
2026-04-19 11:44:43 -07:00
Teknium eb247e6c0a chore: add bingo906 numeric qq email to AUTHOR_MAP
Maps 906014227@qq.com → bingo906 for PR #12450 attribution in the
weekly release notes.
2026-04-19 11:36:04 -07:00
Teknium 014248567b fix(feishu): hydrate bot open_id for manual-setup users
Extends _hydrate_bot_identity() to also populate _bot_open_id (not just
_bot_name) by probing /open-apis/bot/v3/info — the same endpoint the
scan-to-create wizard uses. No extra scopes required beyond the tenant
access token.

Closes the manual-setup gap in #12450: users who configured Feishu
without running the wizard, and never set FEISHU_BOT_OPEN_ID, now get
a bot identity that _is_self_sent_bot_message() can actually use to
filter the adapter's own bot-sent events.

Each field is hydrated independently:
  - Env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID / FEISHU_BOT_NAME)
    still take precedence and skip their respective probe.
  - /bot/v3/info provides open_id + name.
  - Application-info endpoint remains as a best-effort fallback for
    bot_name only (needs admin:app.info:readonly scope).

Tests: 5 new cases covering env-var precedence, probe success, probe
failure fallback, and the end-to-end self-send filter gate after
hydration.
2026-04-19 11:36:04 -07:00
Bingo 2d54e17b82 fix(feishu): allow bot-originated mentions from other bots 2026-04-19 11:36:04 -07:00
Teknium f336ae3d7d fix(environments): use incremental UTF-8 decoder in select-based drain
The first draft of the fix called `chunk.decode("utf-8")` directly on
each 4096-byte `os.read()` result, which corrupts output whenever a
multi-byte UTF-8 character straddles a read boundary:

  * `UnicodeDecodeError` fires on the valid-but-truncated byte sequence.
  * The except handler clears ALL previously-decoded output and replaces
    the whole buffer with `[binary output detected ...]`.

Empirically: 10000 '日' chars (30001 bytes) through the wrapper loses
all 10000 characters on the first draft; the baseline TextIOWrapper
drain (which uses `encoding='utf-8', errors='replace'` on Popen)
preserves them all. This regression affects any command emitting
non-ASCII output larger than one chunk — CJK/Arabic/emoji in
`npm install`, `pip install`, `docker logs`, `kubectl logs`, etc.

Fix: swap to `codecs.getincrementaldecoder('utf-8')(errors='replace')`,
which buffers partial multi-byte sequences across chunks and substitutes
U+FFFD for genuinely invalid bytes. Flush on drain exit via
`decoder.decode(b'', final=True)` to emit any trailing replacement
character for a dangling partial sequence.

Adds two regression tests:
  * test_utf8_multibyte_across_read_boundary — 10000 U+65E5 chars,
    verifies count round-trips and no fallback fires.
  * test_invalid_utf8_uses_replacement_not_fallback — deliberate
    \xff\xfe between valid ASCII, verifies surrounding text survives.
2026-04-19 11:27:50 -07:00
Teknium 0a02fbd842 fix(environments): prevent terminal hang when commands background children (#8340)
When a user's command backgrounds a child (`cmd &`, `setsid cmd & disown`,
etc.), the backgrounded grandchild inherits the write-end of our stdout
pipe via fork(). The old `for line in proc.stdout` drain never EOF'd
until the grandchild closed the pipe — so for a uvicorn server, the
terminal tool hung indefinitely (users reported the whole session
deadlocking when asking the agent to restart a backend).

Fix: switch _drain() to select()-based non-blocking reads and stop
draining shortly after bash exits even if the pipe hasn't EOF'd. Any
output the grandchild writes after that point goes to an orphaned pipe,
which is exactly what the user asked for when they said '&'.

Adds regression tests covering the issue's exact repro and 5 related
patterns (plain bg, setsid+disown, streaming output, high volume,
timeout, UTF-8).
2026-04-19 11:27:50 -07:00
Teknium 611657487f docs(providers): call out Bedrock as not covered by request_timeout_seconds
AWS Bedrock paths (bedrock_converse + AnthropicBedrock SDK) use boto3
with its own timeout config and are not wired to the per-provider knob.
Documented in cli-config.yaml.example and website configuration.md so
users don't expect it to take effect there.
2026-04-19 11:23:00 -07:00
Teknium c11ab6f64d feat(providers): enforce request_timeout_seconds on OpenAI-wire primary calls
Live test with timeout_seconds: 0.5 on claude-sonnet-4.6 proved the
initial wiring was insufficient: run_agent.py was overriding the
client-level timeout on every call via hardcoded per-request kwargs.

Root cause: run_agent.py had two sites that pass an explicit timeout=
kwarg into chat.completions.create() — api_kwargs['timeout'] at line
7075 (HERMES_API_TIMEOUT=1800s default) and the streaming path's
_httpx.Timeout(..., read=HERMES_STREAM_READ_TIMEOUT=120s, ...) at line
5760. Both override the per-provider config value the client was
constructed with, so a 0.5s config timeout would silently not enforce.

This commit:
- Adds AIAgent._resolved_api_call_timeout() — config > HERMES_API_TIMEOUT env > 1800s default.
- Uses it for the non-streaming api_kwargs['timeout'] field.
- Uses it for the streaming path's httpx.Timeout(connect, read, write, pool)
  so both connect and read respect the configured value when set.
  Local-provider auto-bump (Ollama/vLLM cold-start) only applies when
  no explicit config value is set.
- New test: test_resolved_api_call_timeout_priority covers all three
  precedence cases (config, env, default).

Live verified: 0.5s config on claude-sonnet-4.6 now triggers
APITimeoutError at ~3s per retry, exhausts 3 retries in ~15s total
(was: 29-47s success with timeout ignored). Positive case (60s config
+ gpt-4o-mini) still succeeds at 1.3s.
2026-04-19 11:23:00 -07:00
Teknium f1fe29d1c3 feat(providers): extend request_timeout_seconds to all client paths
Follow-up on top of mvanhorn's cherry-picked commit. Original PR only
wired request_timeout_seconds into the explicit-creds OpenAI branch at
run_agent.py init; router-based implicit auth, native Anthropic, and the
fallback chain were still hardcoded to SDK defaults.

- agent/anthropic_adapter.py: build_anthropic_client() accepts an optional
  timeout kwarg (default 900s preserved when unset/invalid).
- run_agent.py: resolve per-provider/per-model timeout once at init; apply
  to Anthropic native init + post-refresh rebuild + stale/interrupt
  rebuilds + switch_model + _restore_primary_runtime + the OpenAI
  implicit-auth path + _try_activate_fallback (with immediate client
  rebuild so the first fallback request carries the configured timeout).
- tests: cover anthropic adapter kwarg honoring; widen mock signatures
  to accept the new timeout kwarg.
- docs/example: clarify that the knob now applies to every transport,
  the fallback chain, and rebuilds after credential rotation.
2026-04-19 11:23:00 -07:00
Matt Van Horn 3143d32330 feat(providers): add per-provider and per-model request_timeout_seconds config
Adds optional providers.<id>.request_timeout_seconds and
providers.<id>.models.<model>.timeout_seconds config, resolved via a new
hermes_cli/timeouts.py helper and applied where client_kwargs is built
in run_agent.py. Zero default behavior change: when both keys are unset,
the openai SDK default takes over.

Mirrors the existing _get_task_timeout pattern in agent/auxiliary_client.py
for auxiliary tasks - the primary turn path just never got the equivalent
knob.

Cross-project demand: openclaw/openclaw#43946 (17 reactions) asks for
exactly this config - specifically calls out Ollama cold-start hanging
the client.
2026-04-19 11:23:00 -07:00
Dusk1e fd119a1c4a fix(agent): refresh skills prompt cache when disabled skills change 2026-04-19 11:16:24 -07:00
Teknium 7e3b356574 refactor(discord): slim down the race-polish fix (#12644)
PR #12558 was heavy for what the fix actually is — essay-length
comments, a dedicated helper method where a setdefault would do, and
a source-inspection test with no real behavior coverage.  The
genuine code change is ~5 lines of new logic (1 field, 2 async with,
an on_ready wait block).

Trimmed:
- Replaced the 12-line _voice_lock_for helper with a setdefault
  one-liner at each call site (join_voice_channel, leave_voice_channel).
- Collapsed the 12-line comment on on_message's _ready_event wait to
  3 lines.  Dropped the warning log on timeout — pass-on-timeout is
  fine; if on_ready hangs that long, the bot is already broken and
  the log wouldn't help.
- Dropped the source-inspection test (greps the module source for
  expected substrings).  It was low-value scaffolding; the
  voice-serialization test covers actual behavior.

Net: -73 lines vs PR #12558.  Same two guarantees preserved, same
test passes (verified by stashing the fix and confirming failure).
2026-04-19 11:08:10 -07:00
Teknium 5a23f3291a fix(model_switch): section 3 base_url/model/dedup follow-up
On top of the salvaged PR #12505 (Jason/farion1231, which adds dict-format
models: enumeration to both sections), three section-3 refinements from
competing PR #11534 (YangManBOBO):

- accept base_url as canonical (matches Hermes's writer and custom_providers
  entries); keep api/url as fallbacks for legacy/hand-edited configs
- accept singular model as a default_model synonym, matching custom_providers
- add seen_slugs guard so the same provider slug appearing in both
  providers: dict and custom_providers: list emits exactly one picker row
  (providers: dict wins since section 3 runs first)

Two regression tests cover the new behavior. AUTHOR_MAP entry added for
farion1231 so CI doesn't reject the cherry-picked commit.
2026-04-19 11:07:29 -07:00
Jason bca03eab20 fix(model_switch): enumerate dict-format models in /model picker
list_authenticated_providers() builds /model picker rows for CLI, TUI and
gateway flows, but fails to enumerate custom provider models stored in
dict form:

- custom_providers[] entries surface only the singular `model:` field,
  hiding every other model in the `models:` dict.
- providers: dict entries with dict-format `models:` are silently dropped
  and render as `(0 models)`.

Hermes's own writer (main.py::_save_custom_provider) persists configured
models as a dict keyed by model id, and most downstream readers
(agent/models_dev.py, gateway/run.py, run_agent.py, hermes_cli/config.py)
already consume that dict format. The /model picker was the only stale
path.

Add a dict branch in both sections of list_authenticated_providers(),
preferring dict (canonical) and keeping the list branch as fallback for
hand-edited / legacy configs. Dedup against the already-added default
model so nothing duplicates when the default is also a dict key.

Six new regression tests in tests/hermes_cli/ cover: dict models with a
default, dict models without a default, and default dedup against a
matching dict key.

Fixes #11677
Fixes #9148
Related: #11017
2026-04-19 11:07:29 -07:00
Teknium 13294c2d18 feat(compression): summaries now respect the conversation's language
Context compaction summaries were always produced in English regardless
of the conversation language, which injected English context into
non-English conversations and muddied the continuation experience.

Adds a one-sentence instruction to the shared `_summarizer_preamble`
used by both the initial-compaction and iterative-update prompt paths.
Placing it in the preamble (rather than adding it separately to each
prompt) means both code paths stay in sync with one edit.

Ported from anomalyco/opencode#20581. The original PR (#4670) landed
before main's prompt templates were refactored to share the
`_summarizer_preamble` and `_template_sections` blocks, so the
cherry-pick conflicted on the now-obsolete inline sections; re-applied
the essential one-line change on top of the current structure.

Verified: 48/48 existing compressor tests pass.
2026-04-19 11:05:14 -07:00
kshitijk4poor 7bd1a3a4b1 test(compression): cover real init feasibility override 2026-04-19 10:40:26 -07:00
kshitijk4poor 045b28733e fix(compression): resolve missing config attribute in feasibility check
Commit 4a9c3565 added a reference to `self.config` in
`_check_compression_model_feasibility()` to pass the user-configured
`auxiliary.compression.context_length` to `get_model_context_length()`.
However, `AIAgent` never stores the loaded config dict as an instance
attribute — the config is loaded into a local variable `_agent_cfg` in
`__init__()` and discarded after init.

This causes an `AttributeError: 'AIAgent' object has no attribute
'config'` on every session start when compression is enabled, caught by
the try/except and logged as a non-fatal DEBUG message.

Fix: store the loaded config as `self._config` in `__init__()` and
update the reference in the feasibility check to use `self._config`.
2026-04-19 10:40:26 -07:00
brooklyn! 6af04474a3 Merge pull request #12560 from NousResearch/bb/tui-gateway-rpc-pool
fix(tui-gateway): dispatch slow RPC handlers on a thread pool (#12546)
2026-04-19 09:49:39 -05:00
Austin Pickett 923539a46b fix: add nous-research/ui package 2026-04-19 10:48:56 -04:00
Brooklyn Nicholson d32e8d2ace fix(tui): drain message queue on every busy → false transition
Previously the queue only drained inside the message.complete event
handler, so anything enqueued while a shell.exec (!sleep, !cmd) or a
failed agent turn was running would stay stuck forever — neither of
those paths emits message.complete. After Ctrl+C an interrupted
session would also orphan the queue because idle() flips busy=false
locally without going through message.complete.

Single source of truth: a useEffect that watches ui.busy. When the
session is settled (sid present, busy false, not editing a queue
item), pull one message and send it. Covers agent turn end,
interrupt, shell.exec completion, error recovery, and the original
startup hydration (first-sid case) all at once.

Dropped the now-redundant dequeue/sendQueued from
createGatewayEventHandler.message.complete and the accompanying
GatewayEventHandlerContext.composer field — the effect handles it.
2026-04-19 08:56:29 -05:00
Brooklyn Nicholson 393175e60c chore(tui-gateway): inline _run_and_emit — one-off wrapper, belongs inside dispatch 2026-04-19 07:58:33 -05:00
Brooklyn Nicholson 596280a40b chore(tui): /clean pass — inline one-off locals, tighten ConfirmPrompt
- providers.ts: drop the `dup` intermediate, fold the ternary inline
- paths.ts (fmtCwdBranch): inline `b` into the `tag` template
- prompts.tsx (ConfirmPrompt): hoist a single `lower = ch.toLowerCase()`,
  collapse the three early-return branches into two, drop the
  redundant bounds checks on arrow-key handlers (setSel is idempotent
  at 0/1), inline the `confirmLabel`/`cancelLabel` defaults at the
  use site
- modelPicker.tsx / config/env.ts / providers.test.ts: auto-formatter
  reflows picked up by `npm run fix`
- useInputHandlers.ts: drop the stray blank line that was tripping
  perfectionist/sort-imports (pre-existing lint error)
2026-04-19 07:55:38 -05:00
Brooklyn Nicholson ab6eaaff26 chore(tui-gateway): inline one-off RPC_POOL_WORKERS, compact _LONG_HANDLERS 2026-04-19 07:53:01 -05:00
Brooklyn Nicholson a6fe5d0872 fix(tui-gateway): dispatch slow RPC handlers on a thread pool (#12546)
The stdin-read loop in entry.py calls handle_request() inline, so the
five handlers that can block for seconds to minutes
(slash.exec, cli.exec, shell.exec, session.resume, session.branch)
freeze the dispatcher. While one is running, any inbound RPC —
notably approval.respond and session.interrupt — sits unread in the
pipe buffer and lands only after the slow handler returns.

Route only those five onto a small ThreadPoolExecutor; every other
handler stays on the main thread so the fast-path ordering is
unchanged and the audit surface stays small. write_json is already
_stdout_lock-guarded, so concurrent response writes are safe. Pool
size defaults to 4 (overridable via HERMES_TUI_RPC_POOL_WORKERS).

- add _LONG_HANDLERS set + ThreadPoolExecutor + atexit shutdown
- new dispatch(req) function: pool for long handlers, inline for rest
- _run_and_emit wraps pool work in a try/except so a misbehaving
  handler still surfaces as a JSON-RPC error instead of silently
  dying in a worker
- entry.py swaps handle_request → dispatch
- 5 new tests: sync path still inline, long handlers emit via stdout,
  fast handler not blocked behind slow one, handler exceptions map to
  error responses, non-long methods always take the sync path

Manual repro confirms the fix: shell.exec(sleep 3) + terminal.resize
sent back-to-back now returns the resize response at t=0s while the
sleep finishes independently at t=3s. Before, both landed together
at t=3s.

Fixes #12546.
2026-04-19 07:47:15 -05:00
Teknium a521005fe5 fix(discord): close two low-severity adapter races (#12558)
Two small races in gateway/platforms/discord.py, bundled together
since they're adjacent in the adapter and both narrow in impact.

1. on_message vs _resolve_allowed_usernames (startup window)
   DISCORD_ALLOWED_USERS accepts both numeric IDs and raw usernames.
   At connect-time, _resolve_allowed_usernames walks the bot's guilds
   (fetch_members can take multiple seconds) to swap usernames for IDs.
   on_message can fire during that window; _is_allowed_user compares
   the numeric author.id against a set that may still contain raw
   usernames — legitimate users get silently rejected for a few
   seconds after every reconnect.

   Fix: on_message awaits _ready_event (with a 30s timeout) when it
   isn't already set.  on_ready sets the event after the resolve
   completes.  In steady state this is a no-op (event already set);
   only the startup / reconnect window ever blocks.

2. join_voice_channel check-and-connect
   The existing-connection check at _voice_clients.get() and the
   channel.connect() call straddled an await boundary with no lock.
   Two concurrent /voice channel invocations could both see None and
   both call connect(); discord.py raises ClientException
   ("Already connected") on the loser.  Same race class for leave
   running concurrently with _voice_timeout_handler.

   Fix: per-guild asyncio.Lock (_voice_locks dict with lazy alloc via
   _voice_lock_for).  join_voice_channel and leave_voice_channel both
   run their body under the lock.  Sequential within a guild, still
   fully concurrent across guilds.

Both: LOW severity.  The first only affects username-based allowlists
on fast-follow-up messages at startup; the second is a narrow
exception on simultaneous voice commands.  Bundled so the adapter
gets a single coherent polish pass.

Tests (tests/gateway/test_discord_race_polish.py): 2 regression cases.
- test_concurrent_joins_do_not_double_connect: two concurrent
  join_voice_channel calls on the same guild result in exactly one
  channel.connect() invocation.
- test_on_message_blocks_until_ready_event_set: asserts the expected
  wait pattern is present in on_message (source inspection, since
  full discord.py client setup isn't practical here).

Regression-guard validated: against unpatched gateway/platforms/discord.py
both tests fail.  With the fix they pass.  Full Discord suite (118
tests) green.
2026-04-19 05:45:59 -07:00
Teknium c567adb58a fix(tui): session.create build thread must clean up if session.close races (#12555)
When a user hits /new or /resume before the previous session finishes
initializing, session.close runs while the previous session.create's
_build thread is still constructing the agent.  session.close pops
_sessions[sid] and closes whatever slash_worker it finds (None at that
point — _build hasn't installed it yet), then returns.  _build keeps
running in the background, installs the slash_worker subprocess and
registers an approval-notify callback on a session dict that's now
unreachable via _sessions.  The subprocess leaks until process exit;
the notify callback lingers in the global registry.

Fix: _build now tracks what it allocates (worker, notify_registered)
and checks in its finally block whether _sessions[sid] still points
to the session it's building for.  If not, the build was orphaned by
a racing close, so clean up the subprocess and unregister the notify
ourselves.

tui_gateway/server.py:
- _build reads _sessions.get(sid) safely (returns early if already gone)
- tracks allocated worker + notify registration
- finally checks orphan status and cleans up

Tests (tests/test_tui_gateway_server.py): 2 new cases.
- test_session_create_close_race_does_not_orphan_worker: slow
  _make_agent, close mid-build, verify worker.close() and
  unregister_gateway_notify both fire from the build thread's
  cleanup path.
- test_session_create_no_race_keeps_worker_alive: regression guard —
  happy path does NOT over-eagerly clean up a live worker.

Validated: against the unpatched code, the race test fails with
'orphan worker was not cleaned up — closed_workers=[]'.  Live E2E
against the live Python environment confirmed the cleanup fires
exactly when the race happens.
2026-04-19 05:35:45 -07:00
Teknium 37524a574e docs: add PR review guides, rework quickstart, slim down installation
Adds two complementary GitHub PR review guides from contest submissions:
- Cron-based PR review agent (from PR #5836 by @dieutx) — polls on a
  schedule, no server needed, teaches skills + memory authoring
- Webhook-based PR review (from PR #6503 by @gaijinkush) — real-time via
  GitHub webhooks, documents previously undocumented webhook feature
Both guides are cross-linked so users can pick the approach that fits.

Reworks quickstart.md by integrating the best content from PR #5744
by @aidil2105:
- Opinionated decision table ('The fastest path')
- Common failure modes table with causes and fixes
- Recovery toolkit sequence
- Session lifecycle verification step
- Better first-chat guidance with example prompts

Slims down installation.md:
- Removes 10-step manual/dev install section (already covered in
  developer-guide/contributing.md)
- Links to Contributing guide for dev setup
- Keeps focused on the automated installer + prerequisites + troubleshooting
2026-04-19 05:30:50 -07:00
Teknium d5fc8a5e00 fix(tui): reject /model and agent-mutating slash passthroughs while running (#12548)
agent.switch_model() mutates self.model, self.provider, self.base_url,
self.api_key, self.api_mode, and rebuilds self.client / self._anthropic_client
in place.  The worker thread running agent.run_conversation reads those
fields on every iteration.  A concurrent config.set key=model or slash-
worker-mirrored /model / /personality / /prompt / /compress can send an
HTTP request with mismatched model + base_url (or the old client keeps
running against a new endpoint) — 400/404s the user never asked for.

Fix: same pattern as the session.undo / session.compress guards
(PR #12416) and the gateway runner's running-agent /model guard (PR
#12334).  Reject with 4009 'session busy' when session.running is True.

Two call sites guarded:
- config.set with key=model: primary /model entry point from Ink
- _mirror_slash_side_effects for model / personality / prompt /
  compress: slash-worker passthrough path that applies live-agent
  side effects

Idle sessions still switch models normally — regression guard test
verifies this.

Tests (tests/test_tui_gateway_server.py): 4 new cases.
- test_config_set_model_rejects_while_running
- test_config_set_model_allowed_when_idle (regression guard)
- test_mirror_slash_side_effects_rejects_mutating_commands_while_running
- test_mirror_slash_side_effects_allowed_when_idle (regression guard)

Validated: against unpatched server.py, the two 'rejects_while_running'
tests fail with the exact race they assert against.  With the fix all
4 pass.  Live E2E against the live Python environment confirmed both
guards enforce 4009 / 'session busy' exactly as designed.
2026-04-19 05:19:57 -07:00
Teknium a3b76ae36d chore(attribution): add AUTHOR_MAP entry for Mibayy
Adds the Mibayy noreply email to the AUTHOR_MAP so CI attribution checks
pass for the #3884 maps skill feat commit (7fa01faf).
2026-04-19 05:19:51 -07:00
Teknium ea0bd81b84 feat(skills): consolidate find-nearby into maps as a single location skill
find-nearby and the (new) maps optional skill both used OpenStreetMap's
Overpass + Nominatim to answer the same question — 'what's near this
location?' — so shipping both would be duplicate code for overlapping
capability. Consolidate into one active-by-default skill at
skills/productivity/maps/ that is a strict superset of find-nearby.

Moves + deletions:
- optional-skills/productivity/maps/ → skills/productivity/maps/ (active,
  no install step needed)
- skills/leisure/find-nearby/ → DELETED (fully superseded)

Upgrades to maps_client.py so it covers everything find-nearby did:
- Overpass server failover — tries overpass-api.de then
  overpass.kumi.systems so a single-mirror outage doesn't break the skill
  (new overpass_query helper, used by both nearby and bbox)
- nearby now accepts --near "<address>" as a shortcut that auto-geocodes,
  so one command replaces the old 'search → copy coords → nearby' chain
- nearby now accepts --category (repeatable) for multi-type queries in
  one call (e.g. --category restaurant --category bar), results merged
  and deduped by (osm_type, osm_id), sorted by distance, capped at --limit
- Each nearby result now includes maps_url (clickable Google Maps search
  link) and directions_url (Google Maps directions from the search point
  — only when a ref point is known)
- Promoted commonly-useful OSM tags to top-level fields on each result:
  cuisine, hours (opening_hours), phone, website — instead of forcing
  callers to dig into the raw tags dict

SKILL.md:
- Version bumped 1.1.0 → 1.2.0, description rewritten to lead with
  capability surface
- New 'Working With Telegram Location Pins' section replacing
  find-nearby's equivalent workflow
- metadata.hermes.supersedes: [find-nearby] so tooling can flag any
  lingering references to the old skill

External references updated:
- optional-skills/productivity/telephony/SKILL.md — related_skills
  find-nearby → maps
- website/docs/reference/skills-catalog.md — removed the (now-empty)
  'leisure' section, added 'maps' row under productivity
- website/docs/user-guide/features/cron.md — find-nearby example
  usages swapped to maps
- tests/tools/test_cronjob_tools.py, tests/hermes_cli/test_cron.py,
  tests/cron/test_scheduler.py — fixture string values swapped
- cli.py:5290 — /cron help-hint example swapped

Not touched:
- RELEASE_v0.2.0.md — historical record, left intact

E2E-verified live (Nominatim + Overpass, one query each):
- nearby --near "Times Square" --category restaurant --category bar → 3 results,
  sorted by distance, all with maps_url, directions_url, cuisine, phone, website
  where OSM had the tags

All 111 targeted tests pass across tests/cron/, tests/tools/, tests/hermes_cli/.
2026-04-19 05:19:22 -07:00
Teknium de491fdf0e chore: remove unit tests from maps skill
Skills are self-contained scripts — they don't need test suites in
the repo.
2026-04-19 05:19:22 -07:00
Mibayy 7fa01fafa5 feat: add maps skill (OpenStreetMap + Overpass + OSRM, no API key)
Adds a maps optional skill with 8 commands, 44 POI categories, and
zero external dependencies. Uses free open data: Nominatim, Overpass
API, OSRM, and TimeAPI.io.

Commands: search, reverse, nearby, distance, directions, timezone,
area, bbox.

Improvements over original PR #2015:
- Fixed directory structure (optional-skills/productivity/maps/)
- Fixed distance argparse (--to flag instead of broken dual nargs=+)
- Fixed timezone (TimeAPI.io instead of broken worldtimeapi heuristic)
- Expanded POI categories from 12 to 44
- Added directions command with turn-by-turn OSRM steps
- Added area command (bounding box + dimensions for a named place)
- Added bbox command (POI search within a geographic rectangle)
- Added 23 unit tests
- Improved haversine (atan2 for numerical stability)
- Comprehensive SKILL.md with workflow examples

Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
2026-04-19 05:19:22 -07:00
Teknium 206a449b29 feat(webhook): direct delivery mode for zero-LLM push notifications (#12473)
External services can now push plain-text notifications to a user's chat
via the webhook adapter without invoking the agent. Set deliver_only=true
on a route and the rendered prompt template becomes the literal message
body — dispatched directly to the configured target (Telegram, Discord,
Slack, GitHub PR comment, etc.).

Reuses all existing webhook infrastructure: HMAC-SHA256 signature
validation, per-route rate limiting, idempotency cache, body-size limits,
template rendering with dot-notation, home-channel fallback. No new HTTP
server, no new auth scheme, no new port.

Use cases: Supabase/Firebase webhooks → user notifications, monitoring
alert forwarding, inter-agent pings, background job completion alerts.

Changes:
- gateway/platforms/webhook.py: new _direct_deliver() helper + early
  dispatch branch in _handle_webhook when deliver_only=true. Startup
  validation rejects deliver_only with deliver=log.
- hermes_cli/main.py + hermes_cli/webhook.go: --deliver-only flag on
  subscribe; list/show output marks direct-delivery routes.
- website/docs/user-guide/messaging/webhooks.md: new Direct Delivery
  Mode section with config example, CLI example, response codes.
- skills/devops/webhook-subscriptions/SKILL.md: document --deliver-only
  with use cases (bumped to v1.1.0).
- tests/gateway/test_webhook_deliver_only.py: 14 new tests covering
  agent bypass, template rendering, status codes, HMAC still enforced,
  idempotency still applies, rate limit still applies, startup
  validation, and direct-deliver dispatch.

Validation: 78 webhook tests pass (64 existing + 14 new). E2E verified
with real aiohttp server + real urllib POST — agent not invoked, target
adapter.send() called with rendered template, duplicate delivery_id
suppressed.

Closes the gap identified in PR #12117 (thanks to @H1an1 / Antenna team)
without adding a second HTTP ingress server.
2026-04-19 05:18:19 -07:00
Teknium 66ee081dc1 skills: move 7 niche mlops/mcp skills to optional (#12474)
Built-in → optional-skills/:
  mlops/training/peft         → optional-skills/mlops/peft
  mlops/training/pytorch-fsdp → optional-skills/mlops/pytorch-fsdp
  mlops/models/clip           → optional-skills/mlops/clip
  mlops/models/stable-diffusion → optional-skills/mlops/stable-diffusion
  mlops/models/whisper        → optional-skills/mlops/whisper
  mlops/cloud/modal           → optional-skills/mlops/modal
  mcp/mcporter                → optional-skills/mcp/mcporter

Built-in mlops training kept: axolotl, trl-fine-tuning, unsloth.
Built-in mlops models kept: audiocraft, segment-anything.
Built-in mlops evaluation/research/huggingface-hub/inference all kept.
native-mcp stays built-in (documents the native MCP tool); mcporter was a
redundant alternative CLI.

Also: removed now-empty skills/mlops/cloud/ dir, refreshed
skills/mlops/models/DESCRIPTION.md and skills/mcp/DESCRIPTION.md to match
what's left, and synchronized both catalog pages (skills-catalog.md,
optional-skills-catalog.md).
2026-04-19 05:14:17 -07:00
kshitijk4poor 957ca79e8e fix(feishu): drop dead helper and cover repeated fenced blocks 2026-04-19 03:30:36 -07:00
kshitijk4poor a9debf10ff fix(feishu): harden fenced post row splitting 2026-04-19 03:30:36 -07:00
sgaofen cc59d133dc fix(feishu): split fenced code blocks in post payload 2026-04-19 03:30:36 -07:00
kshitijk4poor 4f0e49dc7b chore: add sgaofen to AUTHOR_MAP 2026-04-19 03:30:03 -07:00
kshitijk4poor 4b6ff0eb7f fix: tighten gateway interrupt salvage follow-ups
Follow-up on top of the helix4u #12388 cherry-picks:
- make deferred post-delivery callbacks generation-aware end-to-end so
  stale runs cannot clear callbacks registered by a fresher run for the
  same session
- bind callback ownership to the active session event at run start and
  snapshot that generation inside base adapter processing so later event
  mutation cannot retarget cleanup
- pass run_generation through proxy mode and drop stale proxy streams /
  final results the same way local runs are dropped
- centralize stop/new interrupt cleanup into one helper and replace the
  open-coded branches with shared logic
- unify internal control interrupt reason strings via shared constants
- remove the return from base.py's finally block so cleanup no longer
  swallows cancellation/exception flow
- add focused regressions for generation forwarding, proxy stale
  suppression, and newer-callback preservation

This addresses all review findings from the initial #12388 review while
keeping the fix scoped to stale-output/typing-loop interrupt handling.
2026-04-19 03:03:57 -07:00
helix4u 8466268ca5 fix(gateway): keep typing loop overrides backward-compatible 2026-04-19 03:03:57 -07:00
helix4u 150382e8b7 fix(gateway): stop typing loops on session interrupt 2026-04-19 03:03:57 -07:00
helix4u b05d30418d docs: clarify profiles vs workspaces 2026-04-19 02:00:46 -07:00
kshitijk4poor ff63e2e005 fix: tighten telegram docker-media salvage follow-ups
Follow-up on top of the helix4u #6392 cherry-pick:
- reuse one helper for actionable Docker-local file-not-found errors
  across document/image/video/audio local-media send paths
- include /outputs/... alongside /output/... in the container-local
  path hint
- soften the gateway startup warning so it does not imply custom
  host-visible mounts are broken; the warning now targets the specific
  risky pattern of emitting container-local MEDIA paths without an
  explicit export mount
- add focused regressions for /outputs/... and non-document media hint
  coverage

This keeps the salvage aligned with the actual MEDIA delivery problem on
current main while reducing false-positive operator messaging.
2026-04-19 01:55:33 -07:00
helix4u 588333908c fix(telegram): warn on docker-only media paths 2026-04-19 01:55:33 -07:00
Tranquil-Flow b668c09ab2 fix(gateway): strip cursor from frozen message on empty fallback continuation (#7183)
When _send_fallback_final() is called with nothing new to deliver
(the visible partial already matches final_text), the last edit may
still show the cursor character because fallback mode was entered
after a failed edit.  Before this fix the early-return path left
_already_sent = True without attempting to strip the cursor, so the
message stayed frozen with a visible ▉ permanently.

Adds a best-effort edit inside the empty-continuation branch to clean
the cursor off the last-sent text.  Harmless when fallback mode
wasn't actually armed or when the cursor isn't present.  If the strip
edit itself fails (flood still active), we return without crashing
and without corrupting _last_sent_text.

Adapted from PR #7429 onto current main — the surrounding fallback
block grew the #10807 stale-prefix handling since #7429 was written,
so the cursor strip lives in the new else-branch where we still
return early.

3 unit tests covering: cursor stripped on empty continuation, no edit
attempted when cursor is not configured, cursor-strip edit failure
handled without crash.

Originally proposed as PR #7429.
2026-04-19 01:51:12 -07:00
Teknium 62ce6a38ae fix(gateway): cancel_background_tasks must drain late-arrivals (#12471)
During gateway shutdown, a message arriving while
cancel_background_tasks is mid-await (inside asyncio.gather) spawns
a fresh _process_message_background task via handle_message and adds
it to self._background_tasks.  The original implementation's
_background_tasks.clear() at the end of cancel_background_tasks
dropped the reference; the task ran untracked against a disconnecting
adapter, logged send-failures, and lingered until it completed on
its own.

Fix: wrap the cancel+gather in a bounded loop (MAX_DRAIN_ROUNDS=5).
If new tasks appeared during the gather, cancel them in the next
round.  The .clear() at the end is preserved as a safety net for
any task that appeared after MAX_DRAIN_ROUNDS — but in practice the
drain stabilizes in 1-2 rounds.

Tests: tests/gateway/test_cancel_background_drain.py — 3 cases.
- test_cancel_background_tasks_drains_late_arrivals: spawn M1, start
  cancel, inject M2 during M1's shielded cleanup, verify M2 is
  cancelled.
- test_cancel_background_tasks_handles_no_tasks: no-op path still
  terminates cleanly.
- test_cancel_background_tasks_bounded_rounds: baseline — single
  task cancels in one round, loop terminates.

Regression-guard validated: against the unpatched implementation,
the late-arrival test fails with exactly the expected message
('task leaked').  With the fix it passes.

Blast radius is shutdown-only; the audit classified this as MED.
Shipping because the fix is small and the hygiene is worth it.

While investigating the audit's other MEDs (busy-handler double-ack,
Discord ExecApprovalView double-resolve, UpdatePromptView
double-resolve), I verified all three were false positives — the
check-and-set patterns have no await between them, so they're
atomic on single-threaded asyncio.  No fix needed for those.
2026-04-19 01:48:42 -07:00
Arihant Sethia 857b543543 feat: add skill analytics to the dashboard
Expose skill usage in analytics so the dashboard and insights output can
show which skills the agent loads and manages over time.

This adds skill aggregation to the InsightsEngine by extracting
`skill_view` and `skill_manage` calls from assistant tool_calls,
computing per-skill totals, and including the results in both terminal
and gateway insights formatting. It also extends the dashboard analytics
API and Analytics page to render a Top Skills table.

Terminology is aligned with the skills docs:
  - Agent Loaded = `skill_view` events
  - Agent Managed = `skill_manage` actions

Architecture:
  - agent/insights.py collects and aggregates per-skill usage
  - hermes_cli/web_server.py exposes `skills` on `/api/analytics/usage`
  - web/src/lib/api.ts adds analytics skill response types
  - web/src/pages/AnalyticsPage.tsx renders the Top Skills table
  - web/src/i18n/{en,zh}.ts updates user-facing labels

Tests:
  - tests/agent/test_insights.py covers skill aggregation and formatting
  - tests/hermes_cli/test_web_server.py covers analytics API contract
    including the `skills` payload
  - verified with `cd web && npm run build`

Files changed:
  - agent/insights.py
  - hermes_cli/web_server.py
  - tests/agent/test_insights.py
  - tests/hermes_cli/test_web_server.py
  - web/src/i18n/en.ts
  - web/src/i18n/types.ts
  - web/src/i18n/zh.ts
  - web/src/lib/api.ts
  - web/src/pages/AnalyticsPage.tsx
2026-04-15 06:44:43 +00:00
324 changed files with 27809 additions and 3864 deletions
+15 -2
View File
@@ -3,8 +3,13 @@ name: Docker Build and Publish
on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
release:
types: [published]
@@ -49,6 +54,14 @@ jobs:
- name: Test image starts
run: |
# The image runs as the hermes user (UID 10000). GitHub Actions
# creates /tmp/hermes-test root-owned by default, which hermes
# can't write to — chown it to match the in-container UID before
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
# with their own UID hit the same issue and have their own
# remediations (HERMES_UID env var, or chown locally).
mkdir -p /tmp/hermes-test
sudo chown -R 10000:10000 /tmp/hermes-test
docker run --rm \
-v /tmp/hermes-test:/opt/data \
--entrypoint /opt/hermes/docker/entrypoint.sh \
+37 -146
View File
@@ -3,14 +3,31 @@ name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**/*.py'
- '**/*.pth'
- '**/setup.py'
- '**/setup.cfg'
- '**/sitecustomize.py'
- '**/usercustomize.py'
- '**/__init__.pth'
permissions:
pull-requests: write
contents: read
# Narrow, high-signal scanner. Only fires on critical indicators of supply
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
# Actions version unpinning, outbound POST/PUT) were intentionally
# removed — they fired on nearly every PR and trained reviewers to ignore
# the scanner. Keep this file's checks ruthlessly narrow: if you find
# yourself adding WARNING-tier patterns here again, make a separate
# advisory-only workflow instead.
jobs:
scan:
name: Scan PR for supply chain risks
name: Scan PR for critical supply chain risks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -18,7 +35,7 @@ jobs:
with:
fetch-depth: 0
- name: Scan diff for suspicious patterns
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -28,19 +45,19 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Get the full diff (added lines only)
# Added lines only, excluding lockfiles.
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
FINDINGS=""
CRITICAL=false
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. This is the exact mechanism used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512).
Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required.
**Files:**
\`\`\`
@@ -49,13 +66,12 @@ jobs:
"
fi
# --- base64 + exec/eval combo (the litellm attack pattern) ---
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
if [ -n "$B64_EXEC_HITS" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: base64 decode + exec/eval combo
This is the exact pattern used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512) — base64-decoded strings passed to exec/eval to hide credential-stealing payloads.
Base64-decoded strings passed directly to exec/eval — the signature of hidden credential-stealing payloads.
**Matches:**
\`\`\`
@@ -64,41 +80,12 @@ jobs:
"
fi
# --- base64 decode/encode (alone — legitimate uses exist) ---
B64_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|b64encode|decodebytes|encodebytes|urlsafe_b64decode)|atob\(|btoa\(|Buffer\.from\(.*base64' | head -20 || true)
if [ -n "$B64_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: base64 encoding/decoding detected
Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.
**Matches (first 20):**
\`\`\`
${B64_HITS}
\`\`\`
"
fi
# --- exec/eval with string arguments ---
EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E '(exec|eval)\s*\(' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert\|# ' | head -20 || true)
if [ -n "$EXEC_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: exec() or eval() usage
Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.
**Matches (first 20):**
\`\`\`
${EXEC_HITS}
\`\`\`
"
fi
# --- subprocess with encoded/obfuscated commands ---
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|decode|encode|\\x|chr\(' | head -10 || true)
# --- subprocess with encoded/obfuscated command argument ---
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
if [ -n "$PROC_HITS" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
Subprocess calls with encoded arguments are a strong indicator of payload execution.
Subprocess calls whose command strings are base64- or hex-encoded are a strong indicator of payload execution.
**Matches:**
\`\`\`
@@ -107,25 +94,12 @@ jobs:
"
fi
# --- Network calls to non-standard domains ---
EXFIL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'requests\.(post|put)\(|httpx\.(post|put)\(|urllib\.request\.urlopen' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert' | head -10 || true)
if [ -n "$EXFIL_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Outbound network calls (POST/PUT)
Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.
**Matches (first 10):**
\`\`\`
${EXFIL_HITS}
\`\`\`
"
fi
# --- setup.py / setup.cfg install hooks ---
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(setup\.py|setup\.cfg|__init__\.pth|sitecustomize\.py|usercustomize\.py)$' || true)
# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
# These execute during pip install or interpreter startup.
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Install hook files modified
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.
**Files:**
@@ -135,114 +109,31 @@ jobs:
"
fi
# --- Compile/marshal/pickle (code object injection) ---
MARSHAL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'marshal\.loads|pickle\.loads|compile\(' | grep -v '^\+\s*#' | grep -v 'test_\|re\.compile\|ast\.compile' | head -10 || true)
if [ -n "$MARSHAL_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: marshal/pickle/compile usage
These can deserialize or construct executable code objects.
**Matches:**
\`\`\`
${MARSHAL_HITS}
\`\`\`
"
fi
# --- CI/CD workflow files modified ---
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$WORKFLOW_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: CI/CD workflow files modified
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
**Files:**
\`\`\`
${WORKFLOW_HITS}
\`\`\`
"
fi
# --- Dockerfile / container build files modified ---
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
if [ -n "$DOCKER_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Container build files modified
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
**Files:**
\`\`\`
${DOCKER_HITS}
\`\`\`
"
fi
# --- Dependency manifest files modified ---
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
if [ -n "$DEP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Dependency manifest files modified
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
**Files:**
\`\`\`
${DEP_HITS}
\`\`\`
"
fi
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
if [ -n "$ACTIONS_UNPIN" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: GitHub Actions with mutable version tags
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
**Matches:**
\`\`\`
${ACTIONS_UNPIN}
\`\`\`
"
fi
# --- Output results ---
if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
if [ "$CRITICAL" = true ]; then
echo "critical=true" >> "$GITHUB_OUTPUT"
else
echo "critical=false" >> "$GITHUB_OUTPUT"
fi
# Write findings to a file (multiline env vars are fragile)
echo "$FINDINGS" > /tmp/findings.md
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "critical=false" >> "$GITHUB_OUTPUT"
fi
- name: Post warning comment
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SEVERITY="⚠️ Supply Chain Risk Detected"
if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
SEVERITY="🚨 CRITICAL Supply Chain Risk Detected"
fi
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
BODY="## ${SEVERITY}
This PR contains patterns commonly associated with supply chain attacks. This does **not** mean the PR is malicious — but these patterns require careful human review before merging.
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
$(cat /tmp/findings.md)
---
*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.*"
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
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'
if: steps.scan.outputs.found == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
+7 -1
View File
@@ -3,8 +3,14 @@ name: Tests
on:
push:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
permissions:
contents: read
@@ -17,7 +23,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+5
View File
@@ -54,6 +54,11 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
web/public/fonts/
web/public/ds-assets/
# Release script temp files
.release_notes.md
mini-swe-agent/
-2
View File
@@ -27,12 +27,10 @@ WORKDIR /opt/hermes
# Copy only package manifests first so npm install + Playwright are cached
# unless the lockfiles themselves change.
COPY package.json package-lock.json ./
COPY scripts/whatsapp-bridge/package.json scripts/whatsapp-bridge/package-lock.json scripts/whatsapp-bridge/
COPY web/package.json web/package-lock.json web/
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
(cd scripts/whatsapp-bridge && npm install --prefer-offline --no-audit) && \
(cd web && npm install --prefer-offline --no-audit) && \
npm cache clean --force
+41
View File
@@ -20,6 +20,46 @@ from pathlib import Path
from hermes_constants import get_hermes_home
# Methods clients send as periodic liveness probes. They are not part of the
# ACP schema, so the acp router correctly returns JSON-RPC -32601 to the
# caller — but the supervisor task that dispatches the request then surfaces
# the raised RequestError via ``logging.exception("Background task failed")``,
# which dumps a traceback to stderr every probe interval. Clients like
# acp-bridge already treat the -32601 response as "agent alive", so the
# traceback is pure noise. We keep the protocol response intact and only
# silence the stderr noise for this specific benign case.
_BENIGN_PROBE_METHODS = frozenset({"ping", "health", "healthcheck"})
class _BenignProbeMethodFilter(logging.Filter):
"""Suppress acp 'Background task failed' tracebacks caused by unknown
liveness-probe methods (e.g. ``ping``) while leaving every other
background-task error — including method_not_found for any non-probe
method — visible in stderr.
"""
def filter(self, record: logging.LogRecord) -> bool:
if record.getMessage() != "Background task failed":
return True
exc_info = record.exc_info
if not exc_info:
return True
exc = exc_info[1]
# Imported lazily so this module stays importable when the optional
# ``agent-client-protocol`` dependency is not installed.
try:
from acp.exceptions import RequestError
except ImportError:
return True
if not isinstance(exc, RequestError):
return True
if getattr(exc, "code", None) != -32601:
return True
data = getattr(exc, "data", None)
method = data.get("method") if isinstance(data, dict) else None
return method not in _BENIGN_PROBE_METHODS
def _setup_logging() -> None:
"""Route all logging to stderr so stdout stays clean for ACP stdio."""
handler = logging.StreamHandler(sys.stderr)
@@ -29,6 +69,7 @@ def _setup_logging() -> None:
datefmt="%Y-%m-%d %H:%M:%S",
)
)
handler.addFilter(_BenignProbeMethodFilter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
+9 -2
View File
@@ -292,9 +292,15 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
return _COMMON_BETAS
def build_anthropic_client(api_key: str, base_url: str = None):
def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = None):
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
If *timeout* is provided it overrides the default 900s read timeout. The
connect timeout stays at 10s. Callers pass this from the per-provider /
per-model ``request_timeout_seconds`` config so Anthropic-native and
Anthropic-compatible providers respect the same knob as OpenAI-wire
providers.
Returns an anthropic.Anthropic instance.
"""
if _anthropic_sdk is None:
@@ -305,8 +311,9 @@ def build_anthropic_client(api_key: str, base_url: str = None):
from httpx import Timeout
normalized_base_url = _normalize_base_url_text(base_url)
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=900.0, connect=10.0),
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
}
if normalized_base_url:
kwargs["base_url"] = normalized_base_url
+146 -67
View File
@@ -95,51 +95,21 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:
return _PROVIDER_ALIASES.get(normalized, normalized)
_FIXED_TEMPERATURE_MODELS: Dict[str, float] = {
"kimi-for-coding": 0.6,
}
# Moonshot's kimi-for-coding endpoint (api.kimi.com/coding) documents:
# "k2.5 model will use a fixed value 1.0, non-thinking mode will use a fixed
# value 0.6. Any other value will result in an error." The same lock applies
# to the other k2.* models served on that endpoint. Enumerated explicitly so
# non-coding siblings like `kimi-k2-instruct` (variable temperature, served on
# the standard chat API and third parties) are NOT clamped.
# Source: https://platform.kimi.ai/docs/guide/kimi-k2-5-quickstart
_KIMI_INSTANT_MODELS: frozenset = frozenset({
"kimi-k2.5",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
})
_KIMI_THINKING_MODELS: frozenset = frozenset({
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
})
def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]:
def _fixed_temperature_for_model(
model: Optional[str],
base_url: Optional[str] = None,
) -> Optional[float]:
"""Return a required temperature override for models with strict contracts.
Moonshot's kimi-for-coding endpoint rejects any non-approved temperature on
the k2.5 family. Non-thinking variants require exactly 0.6; thinking
variants require 1.0. An optional ``vendor/`` prefix (e.g.
``moonshotai/kimi-k2.5``) is tolerated for aggregator routings.
Returns ``None`` for all models — callers should omit the ``temperature``
parameter so the provider's server-side defaults apply.
Returns ``None`` for every other model, including ``kimi-k2-instruct*``
which is the separate non-coding K2 family with variable temperature.
Kimi / Moonshot models previously had hardcoded temperature overrides here
(0.6 for non-thinking, 1.0 for thinking). As of July 2026 the Kimi gateway
selects the correct temperature server-side based on the active mode, so
client-side clamping is no longer needed (and would conflict if the gateway
changes its defaults).
"""
normalized = (model or "").strip().lower()
fixed = _FIXED_TEMPERATURE_MODELS.get(normalized)
if fixed is not None:
logger.debug("Forcing temperature=%s for model %r (fixed map)", fixed, model)
return fixed
bare = normalized.rsplit("/", 1)[-1]
if bare in _KIMI_THINKING_MODELS:
logger.debug("Forcing temperature=1.0 for kimi thinking model %r", model)
return 1.0
if bare in _KIMI_INSTANT_MODELS:
logger.debug("Forcing temperature=0.6 for kimi instant model %r", model)
return 0.6
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@@ -200,6 +170,45 @@ _CODEX_AUX_MODEL = "gpt-5.2-codex"
_CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
def _codex_cloudflare_headers(access_token: str) -> Dict[str, str]:
"""Headers required to avoid Cloudflare 403s on chatgpt.com/backend-api/codex.
The Cloudflare layer in front of the Codex endpoint whitelists a small set of
first-party originators (``codex_cli_rs``, ``codex_vscode``, ``codex_sdk_ts``,
anything starting with ``Codex``). Requests from non-residential IPs (VPS,
server-hosted agents) that don't advertise an allowed originator are served
a 403 with ``cf-mitigated: challenge`` regardless of auth correctness.
We pin ``originator: codex_cli_rs`` to match the upstream codex-rs CLI, set
``User-Agent`` to a codex_cli_rs-shaped string (beats SDK fingerprinting),
and extract ``ChatGPT-Account-ID`` (canonical casing, from codex-rs
``auth.rs``) out of the OAuth JWT's ``chatgpt_account_id`` claim.
Malformed tokens are tolerated — we drop the account-ID header rather than
raise, so a bad token still surfaces as an auth error (401) instead of a
crash at client construction.
"""
headers = {
"User-Agent": "codex_cli_rs/0.0.0 (Hermes Agent)",
"originator": "codex_cli_rs",
}
if not isinstance(access_token, str) or not access_token.strip():
return headers
try:
import base64
parts = access_token.split(".")
if len(parts) < 2:
return headers
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
if isinstance(acct_id, str) and acct_id:
headers["ChatGPT-Account-ID"] = acct_id
except Exception:
pass
return headers
def _to_openai_base_url(base_url: str) -> str:
"""Normalize an Anthropic-style base URL to OpenAI-compatible format.
@@ -775,6 +784,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
if provider_id == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if "api.kimi.com" in base_url.lower():
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
@@ -796,6 +810,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
if provider_id == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if "api.kimi.com" in base_url.lower():
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
@@ -1016,7 +1035,7 @@ def _validate_base_url(base_url: str) -> None:
) from exc
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
runtime = _resolve_custom_runtime()
if len(runtime) == 2:
custom_base, custom_key = runtime
@@ -1032,6 +1051,23 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
if custom_mode == "codex_responses":
real_client = OpenAI(api_key=custom_key, base_url=custom_base)
return CodexAuxiliaryClient(real_client, model), model
if custom_mode == "anthropic_messages":
# Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
# Anthropic OAuth claims only apply to api.anthropic.com.
try:
from agent.anthropic_adapter import build_anthropic_client
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
"Custom endpoint declares api_mode=anthropic_messages but the "
"anthropic SDK is not installed — falling back to OpenAI-wire."
)
return OpenAI(api_key=custom_key, base_url=custom_base), model
return (
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
model,
)
return OpenAI(api_key=custom_key, base_url=custom_base), model
@@ -1052,7 +1088,11 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
return None, None
base_url = _CODEX_AUX_BASE_URL
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", _CODEX_AUX_MODEL)
real_client = OpenAI(api_key=codex_token, base_url=base_url)
real_client = OpenAI(
api_key=codex_token,
base_url=base_url,
default_headers=_codex_cloudflare_headers(codex_token),
)
return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL
@@ -1348,6 +1388,13 @@ 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.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
if isinstance(sync_client, GeminiNativeClient):
return AsyncGeminiNativeClient(sync_client), model
except ImportError:
pass
try:
from agent.copilot_acp_client import CopilotACPClient
if isinstance(sync_client, CopilotACPClient):
@@ -1512,7 +1559,11 @@ def resolve_provider_client(
"but no Codex OAuth token found (run: hermes model)")
return None, None
final_model = _normalize_resolved_model(model or _CODEX_AUX_MODEL, provider)
raw_client = OpenAI(api_key=codex_token, base_url=_CODEX_AUX_BASE_URL)
raw_client = OpenAI(
api_key=codex_token,
base_url=_CODEX_AUX_BASE_URL,
default_headers=_codex_cloudflare_headers(codex_token),
)
return (raw_client, final_model)
# Standard path: wrap in CodexAuxiliaryClient adapter
client, default = _try_codex()
@@ -1640,6 +1691,15 @@ def resolve_provider_client(
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
final_model = _normalize_resolved_model(model or default_model, provider)
if provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
if is_native_gemini_base_url(base_url):
client = GeminiNativeClient(api_key=api_key, base_url=base_url)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
# Provider-specific headers
headers = {}
if "api.kimi.com" in base_url.lower():
@@ -2190,7 +2250,6 @@ def _resolve_task_provider_model(
to "custom" and the task uses that direct endpoint. api_mode is one of
"chat_completions", "codex_responses", or None (auto-detect).
"""
config = {}
cfg_provider = None
cfg_model = None
cfg_base_url = None
@@ -2198,16 +2257,7 @@ def _resolve_task_provider_model(
cfg_api_mode = None
if task:
try:
from hermes_cli.config import load_config
config = load_config()
except ImportError:
config = {}
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
if not isinstance(task_config, dict):
task_config = {}
task_config = _get_auxiliary_task_config(task)
cfg_provider = str(task_config.get("provider", "")).strip() or None
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
@@ -2237,17 +2287,25 @@ def _resolve_task_provider_model(
_DEFAULT_AUX_TIMEOUT = 30.0
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
"""Read timeout from auxiliary.{task}.timeout in config, falling back to *default*."""
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
"""Return the config dict for auxiliary.<task>, or {} when unavailable."""
if not task:
return default
return {}
try:
from hermes_cli.config import load_config
config = load_config()
except ImportError:
return default
return {}
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
return task_config if isinstance(task_config, dict) else {}
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
"""Read timeout from auxiliary.{task}.timeout in config, falling back to *default*."""
if not task:
return default
task_config = _get_auxiliary_task_config(task)
raw = task_config.get("timeout")
if raw is not None:
try:
@@ -2257,6 +2315,15 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
task_config = _get_auxiliary_task_config(task)
raw = task_config.get("extra_body")
if isinstance(raw, dict):
return dict(raw)
return {}
# ---------------------------------------------------------------------------
# Anthropic-compatible endpoint detection + image block conversion
# ---------------------------------------------------------------------------
@@ -2344,7 +2411,7 @@ def _build_call_kwargs(
"timeout": timeout,
}
fixed_temperature = _fixed_temperature_for_model(model)
fixed_temperature = _fixed_temperature_for_model(model, base_url)
if fixed_temperature is not None:
temperature = fixed_temperature
@@ -2457,6 +2524,8 @@ def call_llm(
"""
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
effective_extra_body = _get_task_extra_body(task)
effective_extra_body.update(extra_body or {})
if task == "vision":
effective_provider, client, final_model = resolve_vision_provider_client(
@@ -2525,11 +2594,14 @@ def call_llm(
task, resolved_provider or "auto", final_model or "default",
f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
# api.moonshot.ai vs api.kimi.com/coding even on auto-detected routes.
kwargs = _build_call_kwargs(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
base_url=_base_info or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
@@ -2583,7 +2655,8 @@ def call_llm(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=extra_body)
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
raise
@@ -2665,6 +2738,8 @@ async def async_call_llm(
"""
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
effective_extra_body = _get_task_extra_body(task)
effective_extra_body.update(extra_body or {})
if task == "vision":
effective_provider, client, final_model = resolve_vision_provider_client(
@@ -2718,14 +2793,17 @@ async def async_call_llm(
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
# api.moonshot.ai vs api.kimi.com/coding even on auto-detected routes.
_client_base = str(getattr(client, "base_url", "") or "")
kwargs = _build_call_kwargs(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
@@ -2761,7 +2839,8 @@ async def async_call_llm(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=extra_body)
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
# Convert sync fallback client to async
async_fb, async_fb_model = _to_async_client(fb_client, fb_model or "")
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
+3 -1
View File
@@ -633,7 +633,9 @@ class ContextCompressor(ContextEngine):
"assistant that continues the conversation. "
"Do NOT respond to any questions or requests in the conversation — "
"only output the structured summary. "
"Do NOT include any preamble, greeting, or prefix."
"Do NOT include any preamble, greeting, or prefix. "
"Write the summary in the same language the user was using in the "
"conversation — do not translate or switch to English."
)
# Shared structured template (used by both paths).
+1 -3
View File
@@ -483,9 +483,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
text=True,
timeout=10,
)
except FileNotFoundError:
return None
except subprocess.TimeoutExpired:
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
+10 -4
View File
@@ -225,9 +225,11 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
content = _oneline(args.get("content", ""))
return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\""
elif action == "replace":
return f"~{target}: \"{_oneline(args.get('old_text', '')[:20])}\""
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
return f"~{target}: \"{old[:20]}\""
elif action == "remove":
return f"-{target}: \"{_oneline(args.get('old_text', '')[:20])}\""
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
return f"-{target}: \"{old[:20]}\""
return action
if tool_name == "send_message":
@@ -939,9 +941,13 @@ def get_cute_tool_message(
if action == "add":
return _wrap(f"┊ 🧠 memory +{target}: \"{_trunc(args.get('content', ''), 30)}\" {dur}")
elif action == "replace":
return _wrap(f"┊ 🧠 memory ~{target}: \"{_trunc(args.get('old_text', ''), 20)}\" {dur}")
old = args.get("old_text") or ""
old = old if old else "<missing old_text>"
return _wrap(f"┊ 🧠 memory ~{target}: \"{_trunc(old, 20)}\" {dur}")
elif action == "remove":
return _wrap(f"┊ 🧠 memory -{target}: \"{_trunc(args.get('old_text', ''), 20)}\" {dur}")
old = args.get("old_text") or ""
old = old if old else "<missing old_text>"
return _wrap(f"┊ 🧠 memory -{target}: \"{_trunc(old, 20)}\" {dur}")
return _wrap(f"┊ 🧠 memory {action} {dur}")
if tool_name == "skills_list":
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
+5 -5
View File
@@ -290,7 +290,7 @@ def classify_api_error(
if isinstance(body, dict):
_err_obj = body.get("error", {})
if isinstance(_err_obj, dict):
_body_msg = (_err_obj.get("message") or "").lower()
_body_msg = str(_err_obj.get("message") or "").lower()
# Parse metadata.raw for wrapped provider errors
_metadata = _err_obj.get("metadata", {})
if isinstance(_metadata, dict):
@@ -302,11 +302,11 @@ def classify_api_error(
if isinstance(_inner, dict):
_inner_err = _inner.get("error", {})
if isinstance(_inner_err, dict):
_metadata_msg = (_inner_err.get("message") or "").lower()
_metadata_msg = str(_inner_err.get("message") or "").lower()
except (json.JSONDecodeError, TypeError):
pass
if not _body_msg:
_body_msg = (body.get("message") or "").lower()
_body_msg = str(body.get("message") or "").lower()
# Combine all message sources for pattern matching
parts = [_raw_msg]
if _body_msg and _body_msg not in _raw_msg:
@@ -606,10 +606,10 @@ def _classify_400(
if isinstance(body, dict):
err_obj = body.get("error", {})
if isinstance(err_obj, dict):
err_body_msg = (err_obj.get("message") or "").strip().lower()
err_body_msg = str(err_obj.get("message") or "").strip().lower()
# Responses API (and some providers) use flat body: {"message": "..."}
if not err_body_msg:
err_body_msg = (body.get("message") or "").strip().lower()
err_body_msg = str(body.get("message") or "").strip().lower()
is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "")
is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80
+16 -7
View File
@@ -39,6 +39,7 @@ from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent import google_oauth
from agent.gemini_schema import sanitize_gemini_tool_parameters
from agent.google_code_assist import (
CODE_ASSIST_ENDPOINT,
FREE_TIER_ID,
@@ -205,7 +206,7 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
decl["description"] = str(fn["description"])
params = fn.get("parameters")
if isinstance(params, dict):
decl["parameters"] = params
decl["parameters"] = sanitize_gemini_tool_parameters(params)
declarations.append(decl)
if not declarations:
return []
@@ -504,9 +505,16 @@ def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
def _translate_stream_event(
event: Dict[str, Any],
model: str,
tool_call_indices: Dict[str, int],
tool_call_counter: List[int],
) -> List[_GeminiStreamChunk]:
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s)."""
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
``tool_call_counter`` is a single-element list used as a mutable counter
across events in the same stream. Each ``functionCall`` part gets a
fresh, unique OpenAI ``index`` — keying by function name would collide
whenever the model issues parallel calls to the same tool (e.g. reading
three files in one turn).
"""
inner = event.get("response") if isinstance(event.get("response"), dict) else event
candidates = inner.get("candidates") or []
if not candidates:
@@ -532,7 +540,8 @@ def _translate_stream_event(
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
name = str(fc["name"])
idx = tool_call_indices.setdefault(name, len(tool_call_indices))
idx = tool_call_counter[0]
tool_call_counter[0] += 1
try:
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
except (TypeError, ValueError):
@@ -549,7 +558,7 @@ def _translate_stream_event(
finish_reason_raw = str(cand.get("finishReason") or "")
if finish_reason_raw:
mapped = _map_gemini_finish_reason(finish_reason_raw)
if tool_call_indices:
if tool_call_counter[0] > 0:
mapped = "tool_calls"
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
return chunks
@@ -733,9 +742,9 @@ class GeminiCloudCodeClient:
# Materialize error body for better diagnostics
response.read()
raise _gemini_http_error(response)
tool_call_indices: Dict[str, int] = {}
tool_call_counter: List[int] = [0]
for event in _iter_sse_events(response):
for chunk in _translate_stream_event(event, model, tool_call_indices):
for chunk in _translate_stream_event(event, model, tool_call_counter):
yield chunk
except httpx.HTTPError as exc:
raise CodeAssistError(
+846
View File
@@ -0,0 +1,846 @@
"""OpenAI-compatible facade over Google AI Studio's native Gemini API.
Hermes keeps ``api_mode='chat_completions'`` for the ``gemini`` provider so the
main agent loop can keep using its existing OpenAI-shaped message flow.
This adapter is the transport shim that converts those OpenAI-style
``messages[]`` / ``tools[]`` requests into Gemini's native
``models/{model}:generateContent`` schema and converts the responses back.
Why this exists
---------------
Google's OpenAI-compatible endpoint has been brittle for Hermes's multi-turn
agent/tool loop (auth churn, tool-call replay quirks, thought-signature
requirements). The native Gemini API is the canonical path and avoids the
OpenAI-compat layer entirely.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import time
import uuid
from types import SimpleNamespace
from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
if not normalized:
return False
if "generativelanguage.googleapis.com" not in normalized:
return False
return not normalized.endswith("/openai")
class GeminiAPIError(Exception):
"""Error shape compatible with Hermes retry/error classification."""
def __init__(
self,
message: str,
*,
code: str = "gemini_api_error",
status_code: Optional[int] = None,
response: Optional[httpx.Response] = None,
retry_after: Optional[float] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
self.response = response
self.retry_after = retry_after
self.details = details or {}
def _coerce_content_to_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
pieces: List[str] = []
for part in content:
if isinstance(part, str):
pieces.append(part)
elif isinstance(part, dict) and part.get("type") == "text":
text = part.get("text")
if isinstance(text, str):
pieces.append(text)
return "\n".join(pieces)
return str(content)
def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]:
if not isinstance(content, list):
text = _coerce_content_to_text(content)
return [{"text": text}] if text else []
parts: List[Dict[str, Any]] = []
for item in content:
if isinstance(item, str):
parts.append({"text": item})
continue
if not isinstance(item, dict):
continue
ptype = item.get("type")
if ptype == "text":
text = item.get("text")
if isinstance(text, str) and text:
parts.append({"text": text})
elif ptype == "image_url":
url = ((item.get("image_url") or {}).get("url") or "")
if not isinstance(url, str) or not url.startswith("data:"):
continue
try:
header, encoded = url.split(",", 1)
mime = header.split(":", 1)[1].split(";", 1)[0]
raw = base64.b64decode(encoded)
except Exception:
continue
parts.append(
{
"inlineData": {
"mimeType": mime,
"data": base64.b64encode(raw).decode("ascii"),
}
}
)
return parts
def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]:
extra = tool_call.get("extra_content") or {}
if not isinstance(extra, dict):
return None
google = extra.get("google") or extra.get("thought_signature")
if isinstance(google, dict):
sig = google.get("thought_signature") or google.get("thoughtSignature")
return str(sig) if isinstance(sig, str) and sig else None
if isinstance(google, str) and google:
return google
return None
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
fn = tool_call.get("function") or {}
args_raw = fn.get("arguments", "")
try:
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
except json.JSONDecodeError:
args = {"_raw": args_raw}
if not isinstance(args, dict):
args = {"_value": args}
part: Dict[str, Any] = {
"functionCall": {
"name": str(fn.get("name") or ""),
"args": args,
}
}
thought_signature = _tool_call_extra_signature(tool_call)
if thought_signature:
part["thoughtSignature"] = thought_signature
return part
def _translate_tool_result_to_gemini(
message: Dict[str, Any],
tool_name_by_call_id: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
tool_name_by_call_id = tool_name_by_call_id or {}
tool_call_id = str(message.get("tool_call_id") or "")
name = str(
message.get("name")
or tool_name_by_call_id.get(tool_call_id)
or tool_call_id
or "tool"
)
content = _coerce_content_to_text(message.get("content"))
try:
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
except json.JSONDecodeError:
parsed = None
response = parsed if isinstance(parsed, dict) else {"output": content}
return {
"functionResponse": {
"name": name,
"response": response,
}
}
def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
system_text_parts: List[str] = []
contents: List[Dict[str, Any]] = []
tool_name_by_call_id: Dict[str, str] = {}
for msg in messages:
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "user")
if role == "system":
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
continue
if role in {"tool", "function"}:
contents.append(
{
"role": "user",
"parts": [
_translate_tool_result_to_gemini(
msg,
tool_name_by_call_id=tool_name_by_call_id,
)
],
}
)
continue
gemini_role = "model" if role == "assistant" else "user"
parts: List[Dict[str, Any]] = []
content_parts = _extract_multimodal_parts(msg.get("content"))
parts.extend(content_parts)
tool_calls = msg.get("tool_calls") or []
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if isinstance(tool_call, dict):
tool_call_id = str(tool_call.get("id") or tool_call.get("call_id") or "")
tool_name = str(((tool_call.get("function") or {}).get("name") or ""))
if tool_call_id and tool_name:
tool_name_by_call_id[tool_call_id] = tool_name
parts.append(_translate_tool_call_to_gemini(tool_call))
if parts:
contents.append({"role": gemini_role, "parts": parts})
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"parts": [{"text": joined_system}]}
return contents, system_instruction
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
if not isinstance(tools, list):
return []
declarations: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function") or {}
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not isinstance(name, str) or not name:
continue
decl: Dict[str, Any] = {"name": name}
description = fn.get("description")
if isinstance(description, str) and description:
decl["description"] = description
parameters = fn.get("parameters")
if isinstance(parameters, dict):
decl["parameters"] = sanitize_gemini_tool_parameters(parameters)
declarations.append(decl)
return [{"functionDeclarations": declarations}] if declarations else []
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
if tool_choice is None:
return None
if isinstance(tool_choice, str):
if tool_choice == "auto":
return {"functionCallingConfig": {"mode": "AUTO"}}
if tool_choice == "required":
return {"functionCallingConfig": {"mode": "ANY"}}
if tool_choice == "none":
return {"functionCallingConfig": {"mode": "NONE"}}
if isinstance(tool_choice, dict):
fn = tool_choice.get("function") or {}
name = fn.get("name")
if isinstance(name, str) and name:
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}}
return None
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
if not isinstance(config, dict) or not config:
return None
budget = config.get("thinkingBudget", config.get("thinking_budget"))
include = config.get("includeThoughts", config.get("include_thoughts"))
level = config.get("thinkingLevel", config.get("thinking_level"))
normalized: Dict[str, Any] = {}
if isinstance(budget, (int, float)):
normalized["thinkingBudget"] = int(budget)
if isinstance(include, bool):
normalized["includeThoughts"] = include
if isinstance(level, str) and level.strip():
normalized["thinkingLevel"] = level.strip().lower()
return normalized or None
def build_gemini_request(
*,
messages: List[Dict[str, Any]],
tools: Any = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stop: Any = None,
thinking_config: Any = None,
) -> Dict[str, Any]:
contents, system_instruction = _build_gemini_contents(messages)
request: Dict[str, Any] = {"contents": contents}
if system_instruction:
request["systemInstruction"] = system_instruction
gemini_tools = _translate_tools_to_gemini(tools)
if gemini_tools:
request["tools"] = gemini_tools
tool_config = _translate_tool_choice_to_gemini(tool_choice)
if tool_config:
request["toolConfig"] = tool_config
generation_config: Dict[str, Any] = {}
if temperature is not None:
generation_config["temperature"] = temperature
if max_tokens is not None:
generation_config["maxOutputTokens"] = max_tokens
if top_p is not None:
generation_config["topP"] = top_p
if stop:
generation_config["stopSequences"] = stop if isinstance(stop, list) else [str(stop)]
normalized_thinking = _normalize_thinking_config(thinking_config)
if normalized_thinking:
generation_config["thinkingConfig"] = normalized_thinking
if generation_config:
request["generationConfig"] = generation_config
return request
def _map_gemini_finish_reason(reason: str) -> str:
mapping = {
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
"OTHER": "stop",
}
return mapping.get(str(reason or "").upper(), "stop")
def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
sig = part.get("thoughtSignature")
if isinstance(sig, str) and sig:
return {"google": {"thought_signature": sig}}
return None
def _empty_response(model: str) -> SimpleNamespace:
message = SimpleNamespace(
role="assistant",
content="",
tool_calls=None,
reasoning=None,
reasoning_content=None,
reasoning_details=None,
)
choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
usage = SimpleNamespace(
prompt_tokens=0,
completion_tokens=0,
total_tokens=0,
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
)
return SimpleNamespace(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion",
created=int(time.time()),
model=model,
choices=[choice],
usage=usage,
)
def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace:
candidates = resp.get("candidates") or []
if not isinstance(candidates, list) or not candidates:
return _empty_response(model)
cand = candidates[0] if isinstance(candidates[0], dict) else {}
content_obj = cand.get("content") if isinstance(cand, dict) else {}
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
text_pieces: List[str] = []
reasoning_pieces: List[str] = []
tool_calls: List[SimpleNamespace] = []
for index, part in enumerate(parts or []):
if not isinstance(part, dict):
continue
if part.get("thought") is True and isinstance(part.get("text"), str):
reasoning_pieces.append(part["text"])
continue
if isinstance(part.get("text"), str):
text_pieces.append(part["text"])
continue
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
try:
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
except (TypeError, ValueError):
args_str = "{}"
tool_call = SimpleNamespace(
id=f"call_{uuid.uuid4().hex[:12]}",
type="function",
index=index,
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
)
extra_content = _tool_call_extra_from_part(part)
if extra_content:
tool_call.extra_content = extra_content
tool_calls.append(tool_call)
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or ""))
usage_meta = resp.get("usageMetadata") or {}
usage = SimpleNamespace(
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
prompt_tokens_details=SimpleNamespace(
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
),
)
reasoning = "".join(reasoning_pieces) or None
message = SimpleNamespace(
role="assistant",
content="".join(text_pieces) if text_pieces else None,
tool_calls=tool_calls or None,
reasoning=reasoning,
reasoning_content=reasoning,
reasoning_details=None,
)
choice = SimpleNamespace(index=0, message=message, finish_reason=finish_reason)
return SimpleNamespace(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion",
created=int(time.time()),
model=model,
choices=[choice],
usage=usage,
)
class _GeminiStreamChunk(SimpleNamespace):
pass
def _make_stream_chunk(
*,
model: str,
content: str = "",
tool_call_delta: Optional[Dict[str, Any]] = None,
finish_reason: Optional[str] = None,
reasoning: str = "",
) -> _GeminiStreamChunk:
delta_kwargs: Dict[str, Any] = {
"role": "assistant",
"content": None,
"tool_calls": None,
"reasoning": None,
"reasoning_content": None,
}
if content:
delta_kwargs["content"] = content
if tool_call_delta is not None:
tool_delta = SimpleNamespace(
index=tool_call_delta.get("index", 0),
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
type="function",
function=SimpleNamespace(
name=tool_call_delta.get("name") or "",
arguments=tool_call_delta.get("arguments") or "",
),
)
extra_content = tool_call_delta.get("extra_content")
if isinstance(extra_content, dict):
tool_delta.extra_content = extra_content
delta_kwargs["tool_calls"] = [tool_delta]
if reasoning:
delta_kwargs["reasoning"] = reasoning
delta_kwargs["reasoning_content"] = reasoning
delta = SimpleNamespace(**delta_kwargs)
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
return _GeminiStreamChunk(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
object="chat.completion.chunk",
created=int(time.time()),
model=model,
choices=[choice],
usage=None,
)
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
buffer = ""
for chunk in response.iter_text():
if not chunk:
continue
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.rstrip("\r")
if not line:
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
return
try:
payload = json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
continue
if isinstance(payload, dict):
yield payload
def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: Dict[str, Dict[str, Any]]) -> List[_GeminiStreamChunk]:
candidates = event.get("candidates") or []
if not candidates:
return []
cand = candidates[0] if isinstance(candidates[0], dict) else {}
parts = ((cand.get("content") or {}).get("parts") or []) if isinstance(cand, dict) else []
chunks: List[_GeminiStreamChunk] = []
for part_index, part in enumerate(parts):
if not isinstance(part, dict):
continue
if part.get("thought") is True and isinstance(part.get("text"), str):
chunks.append(_make_stream_chunk(model=model, reasoning=part["text"]))
continue
if isinstance(part.get("text"), str) and part["text"]:
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
name = str(fc["name"])
try:
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError):
args_str = "{}"
thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else ""
call_key = json.dumps(
{
"part_index": part_index,
"name": name,
"thought_signature": thought_signature,
},
sort_keys=True,
)
slot = tool_call_indices.get(call_key)
if slot is None:
slot = {
"index": len(tool_call_indices),
"id": f"call_{uuid.uuid4().hex[:12]}",
"last_arguments": "",
}
tool_call_indices[call_key] = slot
emitted_arguments = args_str
last_arguments = str(slot.get("last_arguments") or "")
if last_arguments:
if args_str == last_arguments:
emitted_arguments = ""
elif args_str.startswith(last_arguments):
emitted_arguments = args_str[len(last_arguments):]
slot["last_arguments"] = args_str
chunks.append(
_make_stream_chunk(
model=model,
tool_call_delta={
"index": slot["index"],
"id": slot["id"],
"name": name,
"arguments": emitted_arguments,
"extra_content": _tool_call_extra_from_part(part),
},
)
)
finish_reason_raw = str(cand.get("finishReason") or "")
if finish_reason_raw:
mapped = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw)
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
return chunks
def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
status = response.status_code
body_text = ""
body_json: Dict[str, Any] = {}
try:
body_text = response.text
except Exception:
body_text = ""
if body_text:
try:
parsed = json.loads(body_text)
if isinstance(parsed, dict):
body_json = parsed
except (ValueError, TypeError):
body_json = {}
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
if not isinstance(err_obj, dict):
err_obj = {}
err_status = str(err_obj.get("status") or "").strip()
err_message = str(err_obj.get("message") or "").strip()
details_list = err_obj.get("details") if isinstance(err_obj.get("details"), list) else []
reason = ""
retry_after: Optional[float] = None
metadata: Dict[str, Any] = {}
for detail in details_list:
if not isinstance(detail, dict):
continue
type_url = str(detail.get("@type") or "")
if not reason and type_url.endswith("/google.rpc.ErrorInfo"):
reason_value = detail.get("reason")
if isinstance(reason_value, str):
reason = reason_value
md = detail.get("metadata")
if isinstance(md, dict):
metadata = md
header_retry = response.headers.get("Retry-After") or response.headers.get("retry-after")
if header_retry:
try:
retry_after = float(header_retry)
except (TypeError, ValueError):
retry_after = None
code = f"gemini_http_{status}"
if status == 401:
code = "gemini_unauthorized"
elif status == 429:
code = "gemini_rate_limited"
elif status == 404:
code = "gemini_model_not_found"
if err_message:
message = f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}"
else:
message = f"Gemini returned HTTP {status}: {body_text[:500]}"
return GeminiAPIError(
message,
code=code,
status_code=status,
response=response,
retry_after=retry_after,
details={
"status": err_status,
"reason": reason,
"metadata": metadata,
"message": err_message,
},
)
class _GeminiChatCompletions:
def __init__(self, client: "GeminiNativeClient"):
self._client = client
def create(self, **kwargs: Any) -> Any:
return self._client._create_chat_completion(**kwargs)
class _AsyncGeminiChatCompletions:
def __init__(self, client: "AsyncGeminiNativeClient"):
self._client = client
async def create(self, **kwargs: Any) -> Any:
return await self._client._create_chat_completion(**kwargs)
class _GeminiChatNamespace:
def __init__(self, client: "GeminiNativeClient"):
self.completions = _GeminiChatCompletions(client)
class _AsyncGeminiChatNamespace:
def __init__(self, client: "AsyncGeminiNativeClient"):
self.completions = _AsyncGeminiChatCompletions(client)
class GeminiNativeClient:
"""Minimal OpenAI-SDK-compatible facade over Gemini's native REST API."""
def __init__(
self,
*,
api_key: str,
base_url: Optional[str] = None,
default_headers: Optional[Dict[str, str]] = None,
timeout: Any = None,
http_client: Optional[httpx.Client] = None,
**_: Any,
) -> None:
self.api_key = api_key
normalized_base = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/")
if normalized_base.endswith("/openai"):
normalized_base = normalized_base[: -len("/openai")]
self.base_url = normalized_base
self._default_headers = dict(default_headers or {})
self.chat = _GeminiChatNamespace(self)
self.is_closed = False
self._http = http_client or httpx.Client(
timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0)
)
def close(self) -> None:
self.is_closed = True
try:
self._http.close()
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _headers(self) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"x-goog-api-key": self.api_key,
"User-Agent": "hermes-agent (gemini-native)",
}
headers.update(self._default_headers)
return headers
@staticmethod
def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]:
try:
return False, next(iterator)
except StopIteration:
return True, None
def _create_chat_completion(
self,
*,
model: str = "gemini-2.5-flash",
messages: Optional[List[Dict[str, Any]]] = None,
stream: bool = False,
tools: Any = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stop: Any = None,
extra_body: Optional[Dict[str, Any]] = None,
timeout: Any = None,
**_: Any,
) -> Any:
thinking_config = None
if isinstance(extra_body, dict):
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
request = build_gemini_request(
messages=messages or [],
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
stop=stop,
thinking_config=thinking_config,
)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)
url = f"{self.base_url}/models/{model}:generateContent"
response = self._http.post(url, json=request, headers=self._headers(), timeout=timeout)
if response.status_code != 200:
raise gemini_http_error(response)
try:
payload = response.json()
except ValueError as exc:
raise GeminiAPIError(
f"Invalid JSON from Gemini native API: {exc}",
code="gemini_invalid_json",
status_code=response.status_code,
response=response,
) from exc
return translate_gemini_response(payload, model=model)
def _stream_completion(self, *, model: str, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse"
stream_headers = dict(self._headers())
stream_headers["Accept"] = "text/event-stream"
def _generator() -> Iterator[_GeminiStreamChunk]:
try:
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
if response.status_code != 200:
response.read()
raise gemini_http_error(response)
tool_call_indices: Dict[str, Dict[str, Any]] = {}
for event in _iter_sse_events(response):
for chunk in translate_stream_event(event, model, tool_call_indices):
yield chunk
except httpx.HTTPError as exc:
raise GeminiAPIError(
f"Gemini streaming request failed: {exc}",
code="gemini_stream_error",
) from exc
return _generator()
class AsyncGeminiNativeClient:
"""Async wrapper used by auxiliary_client for native Gemini calls."""
def __init__(self, sync_client: GeminiNativeClient):
self._sync = sync_client
self.api_key = sync_client.api_key
self.base_url = sync_client.base_url
self.chat = _AsyncGeminiChatNamespace(self)
async def _create_chat_completion(self, **kwargs: Any) -> Any:
stream = bool(kwargs.get("stream"))
result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs)
if not stream:
return result
async def _async_stream() -> Any:
while True:
done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, result)
if done:
break
yield chunk
return _async_stream()
async def close(self) -> None:
await asyncio.to_thread(self._sync.close)
+85
View File
@@ -0,0 +1,85 @@
"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset."""
from __future__ import annotations
from typing import Any, Dict, List
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
# object, which is only a subset of OpenAPI 3.0 / JSON Schema. Strip fields
# outside that subset before sending Hermes tool schemas to Google.
_GEMINI_SCHEMA_ALLOWED_KEYS = {
"type",
"format",
"title",
"description",
"nullable",
"enum",
"maxItems",
"minItems",
"properties",
"required",
"minProperties",
"maxProperties",
"minLength",
"maxLength",
"pattern",
"example",
"anyOf",
"propertyOrdering",
"default",
"items",
"minimum",
"maximum",
}
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
"""Return a Gemini-compatible copy of a tool parameter schema.
Hermes tool schemas are OpenAI-flavored JSON Schema and may contain keys
such as ``$schema`` or ``additionalProperties`` that Google's Gemini
``Schema`` object rejects. This helper preserves the documented Gemini
subset and recursively sanitizes nested ``properties`` / ``items`` /
``anyOf`` definitions.
"""
if not isinstance(schema, dict):
return {}
cleaned: Dict[str, Any] = {}
for key, value in schema.items():
if key not in _GEMINI_SCHEMA_ALLOWED_KEYS:
continue
if key == "properties":
if not isinstance(value, dict):
continue
props: Dict[str, Any] = {}
for prop_name, prop_schema in value.items():
if not isinstance(prop_name, str):
continue
props[prop_name] = sanitize_gemini_schema(prop_schema)
cleaned[key] = props
continue
if key == "items":
cleaned[key] = sanitize_gemini_schema(value)
continue
if key == "anyOf":
if not isinstance(value, list):
continue
cleaned[key] = [
sanitize_gemini_schema(item)
for item in value
if isinstance(item, dict)
]
continue
cleaned[key] = value
return cleaned
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
"""Normalize tool parameters to a valid Gemini object schema."""
cleaned = sanitize_gemini_schema(parameters)
if not cleaned:
return {"type": "object", "properties": {}}
return cleaned
+162
View File
@@ -124,6 +124,7 @@ class InsightsEngine:
# Gather raw data
sessions = self._get_sessions(cutoff, source)
tool_usage = self._get_tool_usage(cutoff, source)
skill_usage = self._get_skill_usage(cutoff, source)
message_stats = self._get_message_stats(cutoff, source)
if not sessions:
@@ -135,6 +136,15 @@ class InsightsEngine:
"models": [],
"platforms": [],
"tools": [],
"skills": {
"summary": {
"total_skill_loads": 0,
"total_skill_edits": 0,
"total_skill_actions": 0,
"distinct_skills_used": 0,
},
"top_skills": [],
},
"activity": {},
"top_sessions": [],
}
@@ -144,6 +154,7 @@ class InsightsEngine:
models = self._compute_model_breakdown(sessions)
platforms = self._compute_platform_breakdown(sessions)
tools = self._compute_tool_breakdown(tool_usage)
skills = self._compute_skill_breakdown(skill_usage)
activity = self._compute_activity_patterns(sessions)
top_sessions = self._compute_top_sessions(sessions)
@@ -156,6 +167,7 @@ class InsightsEngine:
"models": models,
"platforms": platforms,
"tools": tools,
"skills": skills,
"activity": activity,
"top_sessions": top_sessions,
}
@@ -284,6 +296,82 @@ class InsightsEngine:
for name, count in tool_counts.most_common()
]
def _get_skill_usage(self, cutoff: float, source: str = None) -> List[Dict]:
"""Extract per-skill usage from assistant tool calls."""
skill_counts: Dict[str, Dict[str, Any]] = {}
if source:
cursor = self._conn.execute(
"""SELECT m.tool_calls, m.timestamp
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.started_at >= ? AND s.source = ?
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
(cutoff, source),
)
else:
cursor = self._conn.execute(
"""SELECT m.tool_calls, m.timestamp
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.started_at >= ?
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
(cutoff,),
)
for row in cursor.fetchall():
try:
calls = row["tool_calls"]
if isinstance(calls, str):
calls = json.loads(calls)
if not isinstance(calls, list):
continue
except (json.JSONDecodeError, TypeError):
continue
timestamp = row["timestamp"]
for call in calls:
if not isinstance(call, dict):
continue
func = call.get("function", {})
tool_name = func.get("name")
if tool_name not in {"skill_view", "skill_manage"}:
continue
args = func.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(args, dict):
continue
skill_name = args.get("name")
if not isinstance(skill_name, str) or not skill_name.strip():
continue
entry = skill_counts.setdefault(
skill_name,
{
"skill": skill_name,
"view_count": 0,
"manage_count": 0,
"last_used_at": None,
},
)
if tool_name == "skill_view":
entry["view_count"] += 1
else:
entry["manage_count"] += 1
if timestamp is not None and (
entry["last_used_at"] is None or timestamp > entry["last_used_at"]
):
entry["last_used_at"] = timestamp
return list(skill_counts.values())
def _get_message_stats(self, cutoff: float, source: str = None) -> Dict:
"""Get aggregate message statistics."""
if source:
@@ -475,6 +563,46 @@ class InsightsEngine:
})
return result
def _compute_skill_breakdown(self, skill_usage: List[Dict]) -> Dict[str, Any]:
"""Process per-skill usage into summary + ranked list."""
total_skill_loads = sum(s["view_count"] for s in skill_usage) if skill_usage else 0
total_skill_edits = sum(s["manage_count"] for s in skill_usage) if skill_usage else 0
total_skill_actions = total_skill_loads + total_skill_edits
top_skills = []
for skill in skill_usage:
total_count = skill["view_count"] + skill["manage_count"]
percentage = (total_count / total_skill_actions * 100) if total_skill_actions else 0
top_skills.append({
"skill": skill["skill"],
"view_count": skill["view_count"],
"manage_count": skill["manage_count"],
"total_count": total_count,
"percentage": percentage,
"last_used_at": skill.get("last_used_at"),
})
top_skills.sort(
key=lambda s: (
s["total_count"],
s["view_count"],
s["manage_count"],
s["last_used_at"] or 0,
s["skill"],
),
reverse=True,
)
return {
"summary": {
"total_skill_loads": total_skill_loads,
"total_skill_edits": total_skill_edits,
"total_skill_actions": total_skill_actions,
"distinct_skills_used": len(skill_usage),
},
"top_skills": top_skills,
}
def _compute_activity_patterns(self, sessions: List[Dict]) -> Dict:
"""Analyze activity patterns by day of week and hour."""
day_counts = Counter() # 0=Monday ... 6=Sunday
@@ -670,6 +798,28 @@ class InsightsEngine:
lines.append(f" ... and {len(report['tools']) - 15} more tools")
lines.append("")
# Skill usage
skills = report.get("skills", {})
top_skills = skills.get("top_skills", [])
if top_skills:
lines.append(" 🧠 Top Skills")
lines.append(" " + "" * 56)
lines.append(f" {'Skill':<28} {'Loads':>7} {'Edits':>7} {'Last used':>11}")
for skill in top_skills[:10]:
last_used = ""
if skill.get("last_used_at"):
last_used = datetime.fromtimestamp(skill["last_used_at"]).strftime("%b %d")
lines.append(
f" {skill['skill'][:28]:<28} {skill['view_count']:>7,} {skill['manage_count']:>7,} {last_used:>11}"
)
summary = skills.get("summary", {})
lines.append(
f" Distinct skills: {summary.get('distinct_skills_used', 0)} "
f"Loads: {summary.get('total_skill_loads', 0):,} "
f"Edits: {summary.get('total_skill_edits', 0):,}"
)
lines.append("")
# Activity patterns
act = report.get("activity", {})
if act.get("by_day"):
@@ -753,6 +903,18 @@ class InsightsEngine:
lines.append(f" {t['tool']}{t['count']:,} calls ({t['percentage']:.1f}%)")
lines.append("")
skills = report.get("skills", {})
if skills.get("top_skills"):
lines.append("**🧠 Top Skills:**")
for skill in skills["top_skills"][:5]:
suffix = ""
if skill.get("last_used_at"):
suffix = f", last used {datetime.fromtimestamp(skill['last_used_at']).strftime('%b %d')}"
lines.append(
f" {skill['skill']}{skill['view_count']:,} loads, {skill['manage_count']:,} edits{suffix}"
)
lines.append("")
# Activity summary
act = report.get("activity", {})
if act.get("busiest_day") and act.get("busiest_hour"):
-1
View File
@@ -116,7 +116,6 @@ DEFAULT_CONTEXT_LENGTHS = {
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
"gpt-5.3-codex-spark": 128000, # Spark variant has reduced 128k context
"gpt-5.1-chat": 128000, # Chat variant has 128k context
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
"gpt-4.1": 1047576,
+9 -3
View File
@@ -152,7 +152,13 @@ MEMORY_GUIDANCE = (
"Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
"state to memory; use session_search to recall those from past transcripts. "
"If you've discovered a new way to do something, solved a problem that could be "
"necessary later, save it as a skill with the skill tool."
"necessary later, save it as a skill with the skill tool.\n"
"Write memories as declarative facts, not instructions to yourself. "
"'User prefers concise responses' ✓ — 'Always respond concisely' ✗. "
"'Project uses pytest with xdist' ✓ — 'Run tests with pytest -n 4' ✗. "
"Imperative phrasing gets re-read as a directive in later sessions and can "
"cause repeated work or override the user's current request. Procedures and "
"workflows belong in skills, not memory."
)
SESSION_SEARCH_GUIDANCE = (
@@ -613,12 +619,14 @@ def build_skills_system_prompt(
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
disabled = get_disabled_skill_names()
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -626,8 +634,6 @@ def build_skills_system_prompt(
_SKILLS_PROMPT_CACHE.move_to_end(cache_key)
return cached
disabled = get_disabled_skill_names()
# ── Layer 2: disk snapshot ────────────────────────────────────────
snapshot = _load_skills_snapshot(skills_dir)
-195
View File
@@ -1,195 +0,0 @@
"""Helpers for optional cheap-vs-strong model routing."""
from __future__ import annotations
import os
import re
from typing import Any, Dict, Optional
from utils import is_truthy_value
_COMPLEX_KEYWORDS = {
"debug",
"debugging",
"implement",
"implementation",
"refactor",
"patch",
"traceback",
"stacktrace",
"exception",
"error",
"analyze",
"analysis",
"investigate",
"architecture",
"design",
"compare",
"benchmark",
"optimize",
"optimise",
"review",
"terminal",
"shell",
"tool",
"tools",
"pytest",
"test",
"tests",
"plan",
"planning",
"delegate",
"subagent",
"cron",
"docker",
"kubernetes",
}
_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
def _coerce_bool(value: Any, default: bool = False) -> bool:
return is_truthy_value(value, default=default)
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Return the configured cheap-model route when a message looks simple.
Conservative by design: if the message has signs of code/tool/debugging/
long-form work, keep the primary model.
"""
cfg = routing_config or {}
if not _coerce_bool(cfg.get("enabled"), False):
return None
cheap_model = cfg.get("cheap_model") or {}
if not isinstance(cheap_model, dict):
return None
provider = str(cheap_model.get("provider") or "").strip().lower()
model = str(cheap_model.get("model") or "").strip()
if not provider or not model:
return None
text = (user_message or "").strip()
if not text:
return None
max_chars = _coerce_int(cfg.get("max_simple_chars"), 160)
max_words = _coerce_int(cfg.get("max_simple_words"), 28)
if len(text) > max_chars:
return None
if len(text.split()) > max_words:
return None
if text.count("\n") > 1:
return None
if "```" in text or "`" in text:
return None
if _URL_RE.search(text):
return None
lowered = text.lower()
words = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()}
if words & _COMPLEX_KEYWORDS:
return None
route = dict(cheap_model)
route["provider"] = provider
route["model"] = model
route["routing_reason"] = "simple_turn"
return route
def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]:
"""Resolve the effective model/runtime for one turn.
Returns a dict with model/runtime/signature/label fields.
"""
route = choose_cheap_model_route(user_message, routing_config)
if not route:
return {
"model": primary.get("model"),
"runtime": {
"api_key": primary.get("api_key"),
"base_url": primary.get("base_url"),
"provider": primary.get("provider"),
"api_mode": primary.get("api_mode"),
"command": primary.get("command"),
"args": list(primary.get("args") or []),
"credential_pool": primary.get("credential_pool"),
},
"label": None,
"signature": (
primary.get("model"),
primary.get("provider"),
primary.get("base_url"),
primary.get("api_mode"),
primary.get("command"),
tuple(primary.get("args") or ()),
),
}
from hermes_cli.runtime_provider import resolve_runtime_provider
explicit_api_key = None
api_key_env = str(route.get("api_key_env") or "").strip()
if api_key_env:
explicit_api_key = os.getenv(api_key_env) or None
try:
runtime = resolve_runtime_provider(
requested=route.get("provider"),
explicit_api_key=explicit_api_key,
explicit_base_url=route.get("base_url"),
)
except Exception:
return {
"model": primary.get("model"),
"runtime": {
"api_key": primary.get("api_key"),
"base_url": primary.get("base_url"),
"provider": primary.get("provider"),
"api_mode": primary.get("api_mode"),
"command": primary.get("command"),
"args": list(primary.get("args") or []),
"credential_pool": primary.get("credential_pool"),
},
"label": None,
"signature": (
primary.get("model"),
primary.get("provider"),
primary.get("base_url"),
primary.get("api_mode"),
primary.get("command"),
tuple(primary.get("args") or ()),
),
}
return {
"model": route.get("model"),
"runtime": {
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
"provider": runtime.get("provider"),
"api_mode": runtime.get("api_mode"),
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
},
"label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
"signature": (
route.get("model"),
runtime.get("provider"),
runtime.get("base_url"),
runtime.get("api_mode"),
runtime.get("command"),
tuple(runtime.get("args") or ()),
),
}
+1
View File
@@ -444,6 +444,7 @@ def _process_batch_worker(args: Tuple) -> Dict[str, Any]:
if not reasoning.get("has_any_reasoning", True):
print(f" 🚫 Prompt {prompt_index} discarded (no reasoning in any turn)")
discarded_no_reasoning += 1
completed_in_batch.append(prompt_index)
continue
# Get and normalize tool stats for consistent schema across all entries
+44 -15
View File
@@ -63,7 +63,38 @@ model:
# Leave unset to use the model's native output ceiling (recommended).
# Set only if you want to deliberately limit individual response length.
#
# max_tokens: 8192
# max_tokens: 8192
# Named provider overrides (optional)
# Use this for per-provider request timeouts, non-stream stale timeouts,
# and per-model exceptions.
# Applies to the primary turn client on every api_mode (OpenAI-wire, native
# Anthropic, and Anthropic-compatible providers), the fallback chain, and
# client rebuilds during credential rotation. For OpenAI-wire chat
# completions (streaming and non-streaming) the configured value is also
# used as the per-request ``timeout=`` kwarg so it wins over the legacy
# HERMES_API_TIMEOUT env var (which still applies when no config is set).
# ``stale_timeout_seconds`` controls the non-streaming stale-call detector and
# wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these
# unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s,
# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s).
#
# Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock
# SDK paths) — those use boto3 with its own timeout configuration.
#
# providers:
# ollama-local:
# request_timeout_seconds: 300 # Longer timeout for local cold-starts
# stale_timeout_seconds: 900 # Explicitly re-enable stale detection on local endpoints
# anthropic:
# request_timeout_seconds: 30 # Fast-fail cloud requests
# models:
# claude-opus-4.6:
# timeout_seconds: 600 # Longer timeout for extended-thinking Opus calls
# openai-codex:
# models:
# gpt-5.4:
# stale_timeout_seconds: 1800 # Longer non-stream stale timeout for slow large-context turns
# =============================================================================
# OpenRouter Provider Routing (only applies when using OpenRouter)
@@ -91,20 +122,6 @@ model:
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data
# # data_collection: "deny"
# =============================================================================
# Smart Model Routing (optional)
# =============================================================================
# Use a cheaper model for short/simple turns while keeping your main model for
# more complex requests. Disabled by default.
#
# smart_model_routing:
# enabled: true
# max_simple_chars: 160
# max_simple_words: 28
# cheap_model:
# provider: openrouter
# model: google/gemini-2.5-flash
# =============================================================================
# Git Worktree Isolation
# =============================================================================
@@ -357,6 +374,18 @@ compression:
# web_extract:
# provider: "auto"
# model: ""
#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
# model: ""
# timeout: 30
# max_concurrency: 3 # Limit parallel summaries to reduce request-burst 429s
# extra_body: {} # Provider-specific OpenAI-compatible request fields
# # Example for providers that support request-body
# # reasoning controls:
# # extra_body:
# # enable_thinking: false
# =============================================================================
# Persistent Memory
+304 -76
View File
@@ -310,12 +310,6 @@ def load_cli_config() -> Dict[str, Any]:
"enabled": True, # Auto-compress when approaching context limit
"threshold": 0.50, # Compress at 50% of model's context limit
},
"smart_model_routing": {
"enabled": False,
"max_simple_chars": 160,
"max_simple_words": 28,
"cheap_model": {},
},
"agent": {
"max_turns": 90, # Default max tool-calling iterations (shared with subagents)
"verbose": False,
@@ -1147,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText:
return _RichText.from_ansi(text or "")
def _strip_markdown_syntax(text: str) -> str:
"""Best-effort markdown marker removal for plain-text display."""
import re
plain = _rich_text_from_ansi(text or "").plain
plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE)
plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE)
# Preserve blockquotes, lists, and checkboxes because they carry structure.
plain = re.sub(r"(```+|~~~+)", "", plain)
plain = re.sub(r"`([^`]*)`", r"\1", plain)
plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain)
plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain)
plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain)
plain = re.sub(r"___([^_]+)___", r"\1", plain)
plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain)
plain = re.sub(r"__([^_]+)__", r"\1", plain)
plain = re.sub(r"\*([^*]+)\*", r"\1", plain)
plain = re.sub(r"_([^_]+)_", r"\1", plain)
plain = re.sub(r"~~([^~]+)~~", r"\1", plain)
plain = re.sub(r"\n{3,}", "\n\n", plain)
return plain.strip("\n")
def _render_final_assistant_content(text: str, mode: str = "render"):
"""Render final assistant content as markdown, stripped text, or raw text."""
from rich.markdown import Markdown
normalized_mode = str(mode or "render").strip().lower()
if normalized_mode == "strip":
return _RichText(_strip_markdown_syntax(text))
if normalized_mode == "raw":
return _rich_text_from_ansi(text or "")
plain = _rich_text_from_ansi(text or "").plain
return Markdown(plain)
def _cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's native renderer.
@@ -1724,10 +1755,30 @@ class HermesCLI:
# streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)
self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False)
self.final_response_markdown = str(
CLI_CONFIG["display"].get("final_response_markdown", "strip")
).strip().lower() or "strip"
if self.final_response_markdown not in {"render", "strip", "raw"}:
self.final_response_markdown = "strip"
# Inline diff previews for write actions (display.inline_diffs in config.yaml)
self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True)
# Submitted multiline user-message preview (display.user_message_preview in config.yaml)
_ump = CLI_CONFIG["display"].get("user_message_preview", {})
if not isinstance(_ump, dict):
_ump = {}
try:
_ump_first_lines = int(_ump.get("first_lines", 2))
except (TypeError, ValueError):
_ump_first_lines = 2
try:
_ump_last_lines = int(_ump.get("last_lines", 2))
except (TypeError, ValueError):
_ump_last_lines = 2
self.user_message_preview_first_lines = max(1, _ump_first_lines)
self.user_message_preview_last_lines = max(0, _ump_last_lines)
# Streaming display state
self._stream_buf = "" # Partial line buffer for line-buffered rendering
self._stream_started = False # True once first delta arrives
@@ -1857,8 +1908,9 @@ class HermesCLI:
fb = [fb] if fb.get("provider") and fb.get("model") else []
self._fallback_model = fb
# Optional cheap-vs-strong routing for simple turns
self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {}
# Signature of the currently-initialised agent's runtime. Used to
# rebuild the agent when provider / model / base_url changes across
# turns (e.g. after /model or credential rotation).
self._active_agent_route_signature = None
# Agent will be initialized on first use
@@ -1869,6 +1921,10 @@ class HermesCLI:
self.conversation_history: List[Dict[str, Any]] = []
self.session_start = datetime.now()
self._resumed = False
# Per-prompt elapsed timer — started at the beginning of each chat turn,
# frozen when the agent thread completes, displayed in the status bar.
self._prompt_start_time: Optional[float] = None # time.time() when turn started
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@@ -1967,6 +2023,44 @@ class HermesCLI:
filled = round((safe_percent / 100) * width)
return f"[{('' * filled) + ('' * max(0, width - filled))}]"
@staticmethod
def _format_prompt_elapsed(prompt_start_time: Optional[float], prompt_duration: float, live: bool = False) -> str:
"""Format per-prompt elapsed time for the status bar.
Always returns a string shows 0s on fresh start before first turn.
Keeps seconds visible at all scales so it increments smoothly:
59s 1m 1m 1s ... 1m 59s 2m 2m 1s ...
59m 59s 1h 1h 0m 1s ...
23h 59m 59s 1d 1d 0h 1m ...
Emoji prefix: when turn is live, when frozen or fresh start.
Uses width-1 (no variation selector) glyphs so the status bar stays
aligned in monospace terminals.
"""
if prompt_start_time is None and prompt_duration == 0.0:
return "⏲ 0s"
elapsed = time.time() - prompt_start_time if prompt_start_time is not None else prompt_duration
elapsed = max(0.0, elapsed)
days = int(elapsed // 86400)
remaining = elapsed % 86400
hours = int(remaining // 3600)
remaining = remaining % 3600
minutes = int(remaining // 60)
seconds = int(remaining % 60)
if days > 0:
time_str = f"{days}d {hours}h {minutes}m"
elif hours > 0:
time_str = f"{hours}h {minutes}m {seconds}s" if seconds else f"{hours}h {minutes}m"
elif minutes > 0:
time_str = f"{minutes}m {seconds}s" if seconds else f"{minutes}m"
else:
time_str = f"{int(elapsed)}s"
emoji = "" if live else ""
return f"{emoji} {time_str}"
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
# Prefer the agent's model name — it updates on fallback.
# self.model reflects the originally configured model and never
@@ -1985,6 +2079,11 @@ class HermesCLI:
"model_name": model_name,
"model_short": model_short,
"duration": format_duration_compact(elapsed_seconds),
"prompt_elapsed": self._format_prompt_elapsed(
getattr(self, "_prompt_start_time", None),
getattr(self, "_prompt_duration", 0.0),
live=getattr(self, "_prompt_start_time", None) is not None,
),
"context_tokens": 0,
"context_length": None,
"context_percent": None,
@@ -2176,6 +2275,9 @@ class HermesCLI:
parts = [f"{snapshot['model_short']}", context_label, percent_label]
parts.append(duration_label)
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
return self._trim_status_bar_text("".join(parts), width)
except Exception:
return f"{self.model if getattr(self, 'model', None) else 'Hermes'}"
@@ -2234,8 +2336,13 @@ class HermesCLI:
(bar_style, percent_label),
("class:status-bar-dim", ""),
("class:status-bar-dim", duration_label),
("class:status-bar", " "),
]
# Position 7: per-prompt elapsed timer (live or frozen)
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-dim", prompt_elapsed))
frags.append(("class:status-bar", " "))
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
if total_width > width:
@@ -2454,6 +2561,61 @@ class HermesCLI:
if flush_text:
self._emit_reasoning_preview(flush_text)
def _format_submitted_user_message_preview(self, user_input: str) -> str:
"""Format the submitted user-message scrollback preview."""
lines = user_input.split("\n")
if len(lines) <= 1:
return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]"
first_lines = int(getattr(self, "user_message_preview_first_lines", 2))
last_lines = int(getattr(self, "user_message_preview_last_lines", 2))
first_lines = max(1, first_lines)
last_lines = max(0, last_lines)
head = lines[:first_lines]
remaining_after_head = max(0, len(lines) - len(head))
tail_count = min(last_lines, remaining_after_head)
tail = lines[-tail_count:] if tail_count else []
hidden_middle_count = len(lines) - len(head) - len(tail)
if hidden_middle_count < 0:
hidden_middle_count = 0
tail = []
preview_lines = [
f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]"
]
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:])
if hidden_middle_count > 0:
noun = "line" if hidden_middle_count == 1 else "lines"
preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]")
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail)
return "\n".join(preview_lines)
def _expand_paste_references(self, text: str | None) -> str:
"""Expand [Pasted text #N -> file] placeholders into file contents."""
if not isinstance(text, str) or "[Pasted text #" not in text:
return text or ""
import re as _re
paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
def _expand_ref(match):
path = Path(match.group(1))
return path.read_text(encoding="utf-8") if path.exists() else match.group(0)
return paste_ref_re.sub(_expand_ref, text)
def _print_user_message_preview(self, user_input: str) -> None:
"""Render a user message using the normal chat scrollback style."""
ChatConsole().print(f"[{_accent_hex()}]{'' * 40}[/]")
text = str(user_input or "")
if "\n" in text:
ChatConsole().print(self._format_submitted_user_message_preview(text))
else:
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]")
def _stream_reasoning_delta(self, text: str) -> None:
"""Stream reasoning/thinking tokens into a dim box above the response.
@@ -2697,6 +2859,8 @@ class HermesCLI:
_tc = getattr(self, "_stream_text_ansi", "")
while "\n" in self._stream_buf:
line, self._stream_buf = self._stream_buf.split("\n", 1)
if self.final_response_markdown == "strip":
line = _strip_markdown_syntax(line)
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
def _flush_stream(self) -> None:
@@ -2714,7 +2878,8 @@ class HermesCLI:
if self._stream_buf:
_tc = getattr(self, "_stream_text_ansi", "")
_cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}")
line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
self._stream_buf = ""
# Close the response box
@@ -2776,6 +2941,39 @@ class HermesCLI:
self._command_status = ""
self._invalidate(min_interval=0.0)
def _open_external_editor(self, buffer=None) -> bool:
"""Open the active input buffer in an external editor."""
app = getattr(self, "_app", None)
if not app:
_cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}")
return False
if self._command_running:
_cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
return False
if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
_cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
return False
target_buffer = buffer or getattr(app, "current_buffer", None)
if target_buffer is None:
_cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}")
return False
try:
existing_text = getattr(target_buffer, "text", "")
expanded_text = self._expand_paste_references(existing_text)
if expanded_text != existing_text and hasattr(target_buffer, "text"):
self._skip_paste_collapse = True
target_buffer.text = expanded_text
if hasattr(target_buffer, "cursor_position"):
target_buffer.cursor_position = len(expanded_text)
# Set skip flag (again) so the text-change event fired when the
# editor closes does not re-collapse the returned content.
self._skip_paste_collapse = True
target_buffer.open_in_editor(validate_and_handle=False)
return True
except Exception as exc:
_cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}")
return False
def _ensure_runtime_credentials(self) -> bool:
"""
Ensure runtime credentials are resolved before agent use.
@@ -2883,24 +3081,36 @@ class HermesCLI:
return True
def _resolve_turn_agent_config(self, user_message: str) -> dict:
"""Resolve model/runtime overrides for a single user turn."""
from agent.smart_model_routing import resolve_turn_route
"""Build the effective model/runtime config for a single user turn.
Always uses the session's primary model/provider. If the user has
toggled `/fast` on and the current model supports Priority
Processing / Anthropic fast mode, attach `request_overrides` so the
API call is marked accordingly.
"""
from hermes_cli.models import resolve_fast_mode_overrides
route = resolve_turn_route(
user_message,
self._smart_model_routing,
{
"model": self.model,
"api_key": self.api_key,
"base_url": self.base_url,
"provider": self.provider,
"api_mode": self.api_mode,
"command": self.acp_command,
"args": list(self.acp_args or []),
"credential_pool": getattr(self, "_credential_pool", None),
},
)
runtime = {
"api_key": self.api_key,
"base_url": self.base_url,
"provider": self.provider,
"api_mode": self.api_mode,
"command": self.acp_command,
"args": list(self.acp_args or []),
"credential_pool": getattr(self, "_credential_pool", None),
}
route = {
"model": self.model,
"runtime": runtime,
"signature": (
self.model,
runtime["provider"],
runtime["base_url"],
runtime["api_mode"],
runtime["command"],
tuple(runtime["args"]),
),
}
service_tier = getattr(self, "service_tier", None)
if not service_tier:
@@ -2908,13 +3118,13 @@ class HermesCLI:
return route
try:
overrides = resolve_fast_mode_overrides(route.get("model"))
overrides = resolve_fast_mode_overrides(route["model"])
except Exception:
overrides = None
route["request_overrides"] = overrides
return route
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None, request_overrides: dict | None = None) -> bool:
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
"""
Initialize the agent on first use.
When resuming a session, restores conversation history from SQLite.
@@ -3941,6 +4151,7 @@ class HermesCLI:
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}")
if _is_termux_environment():
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
else:
@@ -5287,7 +5498,7 @@ class HermesCLI:
print(" /cron list")
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
print(" /cron edit <job_id> --skill blogwatcher --skill maps")
print(" /cron edit <job_id> --remove-skill blogwatcher")
print(" /cron edit <job_id> --clear-skills")
print(" /cron pause <job_id>")
@@ -6040,7 +6251,7 @@ class HermesCLI:
_chat_console = ChatConsole()
_chat_console.print(Panel(
_rich_text_from_ansi(response),
_render_final_assistant_content(response, mode=self.final_response_markdown),
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
title_align="left",
border_style=_resp_color,
@@ -6165,7 +6376,7 @@ class HermesCLI:
_resp_color = "#4F6D4A"
ChatConsole().print(Panel(
_rich_text_from_ansi(response),
_render_final_assistant_content(response, mode=self.final_response_markdown),
title=f"[{_resp_color} bold]⚕ /btw[/]",
title_align="left",
border_style=_resp_color,
@@ -6657,6 +6868,18 @@ class HermesCLI:
focus_topic=focus_topic or None,
)
self.conversation_history = compressed
# _compress_context ends the old session and creates a new child
# session on the agent (run_agent.py::_compress_context). Sync the
# CLI's session_id so /status, /resume, exit summary, and title
# generation all point at the live continuation session, not the
# ended parent. Without this, subsequent end_session() calls target
# the already-closed parent and the child is orphaned.
if (
getattr(self.agent, "session_id", None)
and self.agent.session_id != self.session_id
):
self.session_id = self.agent.session_id
self._pending_title = None
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
summary = summarize_manual_compression(
original_history,
@@ -7911,7 +8134,6 @@ class HermesCLI:
if not self._init_agent(
model_override=turn_route["model"],
runtime_override=turn_route["runtime"],
route_label=turn_route["label"],
request_overrides=turn_route.get("request_overrides"),
):
return None
@@ -8069,6 +8291,10 @@ class HermesCLI:
# 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).
# Start per-prompt elapsed timer — frozen after the agent thread
# finishes; reset on the next turn.
self._prompt_start_time = time.time()
self._prompt_duration = 0.0
agent_thread = threading.Thread(target=run_agent, daemon=True)
agent_thread.start()
@@ -8146,6 +8372,12 @@ class HermesCLI:
# but guard against edge cases.
agent_thread.join(timeout=30)
# Freeze per-prompt elapsed timer once the agent thread has
# exited (or been abandoned as a daemon after interrupt).
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound
# to a per-thread event loop; if that loop is now closed, those
@@ -8176,6 +8408,20 @@ class HermesCLI:
# Update history with full conversation
self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history
# If auto-compression fired mid-turn, the agent created a new
# continuation session and mutated self.agent.session_id. Sync
# the CLI's session_id so /status, /resume, title generation,
# and the exit summary all target the live child session rather
# than the ended parent. Mirrors the gateway's post-run sync
# (gateway/run.py around line 9983).
if (
self.agent
and getattr(self.agent, "session_id", None)
and self.agent.session_id != self.session_id
):
self.session_id = self.agent.session_id
self._pending_title = None
# Get the final response
response = result.get("final_response", "") if result else ""
@@ -8265,7 +8511,7 @@ class HermesCLI:
else:
_chat_console = ChatConsole()
_chat_console.print(Panel(
_rich_text_from_ansi(response),
_render_final_assistant_content(response, mode=self.final_response_markdown),
title=f"[{_resp_color} bold]{label}[/]",
title_align="left",
border_style=_resp_color,
@@ -8831,6 +9077,16 @@ class HermesCLI:
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
@kb.add(
'c-g',
filter=Condition(
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
),
)
def handle_open_in_editor(event):
"""Ctrl+G opens the current draft in an external editor."""
cli_ref._open_external_editor(event.current_buffer)
@kb.add('tab', eager=True)
def handle_tab(event):
"""Tab: accept completion, auto-suggestion, or start completions.
@@ -9282,6 +9538,7 @@ class HermesCLI:
_prev_text_len = [0]
_prev_newline_count = [0]
_paste_just_collapsed = [False]
self._skip_paste_collapse = False
def _on_text_changed(buf):
"""Detect large pastes and collapse them to a file reference.
@@ -9301,8 +9558,9 @@ class HermesCLI:
text = buf.text
chars_added = len(text) - _prev_text_len[0]
_prev_text_len[0] = len(text)
if _paste_just_collapsed[0]:
if _paste_just_collapsed[0] or self._skip_paste_collapse:
_paste_just_collapsed[0] = False
self._skip_paste_collapse = False
_prev_newline_count[0] = text.count('\n')
return
line_count = text.count('\n')
@@ -9311,12 +9569,10 @@ class HermesCLI:
is_paste = chars_added > 1 or newlines_added >= 4
if line_count >= 5 and is_paste and not text.startswith('/'):
_paste_counter[0] += 1
# Save to temp file
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
# Replace buffer with compact reference
_paste_just_collapsed[0] = True
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
buf.cursor_position = len(buf.text)
@@ -10038,45 +10294,9 @@ class HermesCLI:
_paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else []
if paste_refs:
def _expand_ref(m):
p = Path(m.group(1))
return p.read_text(encoding="utf-8") if p.exists() else m.group(0)
expanded = _paste_ref_re.sub(_expand_ref, user_input)
total_lines = expanded.count('\n') + 1
n_pastes = len(paste_refs)
_user_bar = f"[{_accent_hex()}]{'' * 40}[/]"
print()
ChatConsole().print(_user_bar)
# Show any surrounding user text alongside the paste summary
split_parts = _paste_ref_re.split(user_input)
visible_user_text = " ".join(
split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip()
)
if visible_user_text:
ChatConsole().print(
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] "
f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]"
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]"
)
user_input = expanded
else:
_user_bar = f"[{_accent_hex()}]{'' * 40}[/]"
if '\n' in user_input:
first_line = user_input.split('\n')[0]
line_count = user_input.count('\n') + 1
print()
ChatConsole().print(_user_bar)
ChatConsole().print(
f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] "
f"[dim](+{line_count - 1} lines)[/]"
)
else:
print()
ChatConsole().print(_user_bar)
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
user_input = self._expand_paste_references(user_input)
print()
self._print_user_message_preview(user_input)
# Show image attachment count
if submit_images:
@@ -10535,7 +10755,6 @@ def main(
if cli._init_agent(
model_override=turn_route["model"],
runtime_override=turn_route["runtime"],
route_label=turn_route["label"],
request_overrides=turn_route.get("request_overrides"),
):
cli.agent.quiet_mode = True
@@ -10549,6 +10768,15 @@ def main(
user_message=effective_query,
conversation_history=cli.conversation_history,
)
# Sync session_id if mid-run compression created a
# continuation session. The exit line below reports
# session_id to stderr for automation wrappers; without
# this sync it would point at the ended parent.
if (
getattr(cli.agent, "session_id", None)
and cli.agent.session_id != cli.session_id
):
cli.session_id = cli.agent.session_id
response = result.get("final_response", "") if isinstance(result, dict) else str(result)
if response:
print(response)
+8 -24
View File
@@ -826,7 +826,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# Provider routing
pr = _cfg.get("provider_routing", {})
smart_routing = _cfg.get("smart_model_routing", {}) or {}
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
@@ -843,24 +842,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
message = format_runtime_provider_error(exc)
raise RuntimeError(message) from exc
from agent.smart_model_routing import resolve_turn_route
turn_route = resolve_turn_route(
prompt,
smart_routing,
{
"model": model,
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
"provider": runtime.get("provider"),
"api_mode": runtime.get("api_mode"),
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
},
)
fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None
credential_pool = None
runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower()
runtime_provider = str(runtime.get("provider") or "").strip().lower()
if runtime_provider:
try:
from agent.credential_pool import load_pool
@@ -877,13 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
agent = AIAgent(
model=turn_route["model"],
api_key=turn_route["runtime"].get("api_key"),
base_url=turn_route["runtime"].get("base_url"),
provider=turn_route["runtime"].get("provider"),
api_mode=turn_route["runtime"].get("api_mode"),
acp_command=turn_route["runtime"].get("command"),
acp_args=turn_route["runtime"].get("args"),
model=model,
api_key=runtime.get("api_key"),
base_url=runtime.get("base_url"),
provider=runtime.get("provider"),
api_mode=runtime.get("api_mode"),
acp_command=runtime.get("command"),
acp_args=runtime.get("args"),
max_iterations=max_iterations,
reasoning_config=reasoning_config,
prefill_messages=prefill_messages,
+179 -12
View File
@@ -117,6 +117,160 @@ def _normalize_chat_content(
return ""
# Content part type aliases used by the OpenAI Chat Completions and Responses
# APIs. We accept both spellings on input and emit a single canonical internal
# shape (``{"type": "text", ...}`` / ``{"type": "image_url", ...}``) that the
# rest of the agent pipeline already understands.
_TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text"})
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image"})
_FILE_PART_TYPES = frozenset({"file", "input_file"})
def _normalize_multimodal_content(content: Any) -> Any:
"""Validate and normalize multimodal content for the API server.
Returns a plain string when the content is text-only, or a list of
``{"type": "text"|"image_url", ...}`` parts when images are present.
The output shape is the native OpenAI Chat Completions vision format,
which the agent pipeline accepts verbatim (OpenAI-wire providers) or
converts (``_preprocess_anthropic_content`` for Anthropic).
Raises ``ValueError`` with an OpenAI-style code on invalid input:
* ``unsupported_content_type`` file/input_file/file_id parts, or
non-image ``data:`` URLs.
* ``invalid_image_url`` missing URL or unsupported scheme.
* ``invalid_content_part`` malformed text/image objects.
Callers translate the ValueError into a 400 response.
"""
# Scalar passthrough mirrors ``_normalize_chat_content``.
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 not isinstance(content, list):
# Mirror the legacy text-normalizer's fallback so callers that
# pre-existed image support still get a string back.
return _normalize_chat_content(content)
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
normalized_parts: List[Dict[str, Any]] = []
text_accum_len = 0
for part in items:
if isinstance(part, str):
if part:
trimmed = part[:MAX_NORMALIZED_TEXT_LENGTH]
normalized_parts.append({"type": "text", "text": trimmed})
text_accum_len += len(trimmed)
continue
if not isinstance(part, dict):
# Ignore unknown scalars for forward compatibility with future
# Responses API additions (e.g. ``refusal``). The same policy
# the text normalizer applies.
continue
raw_type = part.get("type")
part_type = str(raw_type or "").strip().lower()
if part_type in _TEXT_PART_TYPES:
text = part.get("text")
if text is None:
continue
if not isinstance(text, str):
text = str(text)
if text:
trimmed = text[:MAX_NORMALIZED_TEXT_LENGTH]
normalized_parts.append({"type": "text", "text": trimmed})
text_accum_len += len(trimmed)
continue
if part_type in _IMAGE_PART_TYPES:
detail = part.get("detail")
image_ref = part.get("image_url")
# OpenAI Responses sends ``input_image`` with a top-level
# ``image_url`` string; Chat Completions sends ``image_url`` as
# ``{"url": "...", "detail": "..."}``. Support both.
if isinstance(image_ref, dict):
url_value = image_ref.get("url")
detail = image_ref.get("detail", detail)
else:
url_value = image_ref
if not isinstance(url_value, str) or not url_value.strip():
raise ValueError("invalid_image_url:Image parts must include a non-empty image URL.")
url_value = url_value.strip()
lowered = url_value.lower()
if lowered.startswith("data:"):
if not lowered.startswith("data:image/") or "," not in url_value:
raise ValueError(
"unsupported_content_type:Only image data URLs are supported. "
"Non-image data payloads are not supported."
)
elif not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError(
"invalid_image_url:Image inputs must use http(s) URLs or data:image/... URLs."
)
image_part: Dict[str, Any] = {"type": "image_url", "image_url": {"url": url_value}}
if detail is not None:
if not isinstance(detail, str) or not detail.strip():
raise ValueError("invalid_content_part:Image detail must be a non-empty string when provided.")
image_part["image_url"]["detail"] = detail.strip()
normalized_parts.append(image_part)
continue
if part_type in _FILE_PART_TYPES:
raise ValueError(
"unsupported_content_type:Inline image inputs are supported, "
"but uploaded files and document inputs are not supported on this endpoint."
)
# Unknown part type — reject explicitly so clients get a clear error
# instead of a silently dropped turn.
raise ValueError(
f"unsupported_content_type:Unsupported content part type {raw_type!r}. "
"Only text and image_url/input_image parts are supported."
)
if not normalized_parts:
return ""
# Text-only: collapse to a plain string so downstream logging/trajectory
# code sees the native shape and prompt caching on text-only turns is
# unaffected.
if all(p.get("type") == "text" for p in normalized_parts):
return "\n".join(p["text"] for p in normalized_parts if p.get("text"))
return normalized_parts
def _content_has_visible_payload(content: Any) -> bool:
"""True when content has any text or image attachment. Used to reject empty turns."""
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
ptype = str(part.get("type") or "").strip().lower()
if ptype in _TEXT_PART_TYPES and str(part.get("text") or "").strip():
return True
if ptype in _IMAGE_PART_TYPES:
return True
return False
def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Response":
"""Translate a ``_normalize_multimodal_content`` ValueError into a 400 response."""
raw = str(exc)
code, _, message = raw.partition(":")
if not message:
code, message = "invalid_content_part", raw
return web.json_response(
_openai_error(message, code=code, param=param),
status=400,
)
def check_api_server_requirements() -> bool:
"""Check if API server dependencies are available."""
return AIOHTTP_AVAILABLE
@@ -637,26 +791,32 @@ class APIServerAdapter(BasePlatformAdapter):
system_prompt = None
conversation_messages: List[Dict[str, str]] = []
for msg in messages:
for idx, msg in enumerate(messages):
role = msg.get("role", "")
content = _normalize_chat_content(msg.get("content", ""))
raw_content = msg.get("content", "")
if role == "system":
# Accumulate system messages
# System messages don't support images (Anthropic rejects, OpenAI
# text-model systems don't render them). Flatten to text.
content = _normalize_chat_content(raw_content)
if system_prompt is None:
system_prompt = content
else:
system_prompt = system_prompt + "\n" + content
elif role in ("user", "assistant"):
try:
content = _normalize_multimodal_content(raw_content)
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"messages[{idx}].content")
conversation_messages.append({"role": role, "content": content})
# Extract the last user message as the primary input
user_message = ""
user_message: Any = ""
history = []
if conversation_messages:
user_message = conversation_messages[-1].get("content", "")
history = conversation_messages[:-1]
if not user_message:
if not _content_has_visible_payload(user_message):
return web.json_response(
{"error": {"message": "No user message found in messages", "type": "invalid_request_error"}},
status=400,
@@ -1424,16 +1584,19 @@ class APIServerAdapter(BasePlatformAdapter):
# No error if conversation doesn't exist yet — it's a new conversation
# Normalize input to message list
input_messages: List[Dict[str, str]] = []
input_messages: List[Dict[str, Any]] = []
if isinstance(raw_input, str):
input_messages = [{"role": "user", "content": raw_input}]
elif isinstance(raw_input, list):
for item in raw_input:
for idx, item in enumerate(raw_input):
if isinstance(item, str):
input_messages.append({"role": "user", "content": item})
elif isinstance(item, dict):
role = item.get("role", "user")
content = _normalize_chat_content(item.get("content", ""))
try:
content = _normalize_multimodal_content(item.get("content", ""))
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"input[{idx}].content")
input_messages.append({"role": role, "content": content})
else:
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
@@ -1442,7 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
# This lets stateless clients supply their own history instead of
# relying on server-side response chaining via previous_response_id.
# Precedence: explicit conversation_history > previous_response_id.
conversation_history: List[Dict[str, str]] = []
conversation_history: List[Dict[str, Any]] = []
raw_history = body.get("conversation_history")
if raw_history:
if not isinstance(raw_history, list):
@@ -1456,7 +1619,11 @@ class APIServerAdapter(BasePlatformAdapter):
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
status=400,
)
conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])})
try:
entry_content = _normalize_multimodal_content(entry["content"])
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"conversation_history[{i}].content")
conversation_history.append({"role": str(entry["role"]), "content": entry_content})
if previous_response_id:
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
@@ -1476,8 +1643,8 @@ class APIServerAdapter(BasePlatformAdapter):
conversation_history.append(msg)
# Last input message is the user_message
user_message = input_messages[-1].get("content", "") if input_messages else ""
if not user_message:
user_message: Any = input_messages[-1].get("content", "") if input_messages else ""
if not _content_has_visible_payload(user_message):
return web.json_response(_openai_error("No user message found in input"), status=400)
# Truncation support
+154 -17
View File
@@ -6,6 +6,7 @@ and implement the required methods.
"""
import asyncio
import inspect
import ipaddress
import logging
import os
@@ -551,6 +552,39 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
raise last_exc
# ---------------------------------------------------------------------------
# Video cache utilities
#
# Same pattern as image/audio cache -- videos from platforms are downloaded
# here so the agent can reference them by local file path.
# ---------------------------------------------------------------------------
VIDEO_CACHE_DIR = get_hermes_dir("cache/videos", "video_cache")
SUPPORTED_VIDEO_TYPES = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
}
def get_video_cache_dir() -> Path:
"""Return the video cache directory, creating it if it doesn't exist."""
VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
return VIDEO_CACHE_DIR
def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
"""Save raw video bytes to the cache and return the absolute file path."""
cache_dir = get_video_cache_dir()
filename = f"video_{uuid.uuid4().hex[:12]}{ext}"
filepath = cache_dir / filename
filepath.write_bytes(data)
return str(filepath)
# ---------------------------------------------------------------------------
# Document cache utilities
#
@@ -880,10 +914,11 @@ class BasePlatformAdapter(ABC):
# working on a task after --replace or manual restarts.
self._background_tasks: set[asyncio.Task] = set()
# One-shot callbacks to fire after the main response is delivered.
# Keyed by session_key. GatewayRunner uses this to defer
# background-review notifications ("💾 Skill created") until the
# primary reply has been sent.
self._post_delivery_callbacks: Dict[str, Callable] = {}
# Keyed by session_key. Values are either a bare callback (legacy) or
# a ``(generation, callback)`` tuple so GatewayRunner can make deferred
# deliveries generation-aware and avoid stale runs clearing callbacks
# registered by a fresher run for the same session.
self._post_delivery_callbacks: Dict[str, Any] = {}
self._expected_cancelled_tasks: set[asyncio.Task] = set()
self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None
# Chats where auto-TTS on voice input is disabled (set by /voice off)
@@ -1401,7 +1436,13 @@ class BasePlatformAdapter(ABC):
return paths, cleaned
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None:
async def _keep_typing(
self,
chat_id: str,
interval: float = 2.0,
metadata=None,
stop_event: asyncio.Event | None = None,
) -> None:
"""
Continuously send typing indicator until cancelled.
@@ -1415,9 +1456,18 @@ class BasePlatformAdapter(ABC):
"""
try:
while True:
if stop_event is not None and stop_event.is_set():
return
if chat_id not in self._typing_paused:
await self.send_typing(chat_id, metadata=metadata)
await asyncio.sleep(interval)
if stop_event is None:
await asyncio.sleep(interval)
continue
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
continue
return
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
finally:
@@ -1444,6 +1494,59 @@ class BasePlatformAdapter(ABC):
"""Resume typing indicator for a chat after approval resolves."""
self._typing_paused.discard(chat_id)
async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
"""Signal the active session loop to stop and clear typing immediately."""
if session_key:
interrupt_event = self._active_sessions.get(session_key)
if interrupt_event is not None:
interrupt_event.set()
try:
await self.stop_typing(chat_id)
except Exception:
pass
def register_post_delivery_callback(
self,
session_key: str,
callback: Callable,
*,
generation: int | None = None,
) -> None:
"""Register a deferred callback to fire after the main response.
``generation`` lets callers tie the callback to a specific gateway run
generation so stale runs cannot clear callbacks owned by a fresher run.
"""
if not session_key or not callable(callback):
return
if generation is None:
self._post_delivery_callbacks[session_key] = callback
else:
self._post_delivery_callbacks[session_key] = (int(generation), callback)
def pop_post_delivery_callback(
self,
session_key: str,
*,
generation: int | None = None,
) -> Callable | None:
"""Pop a deferred callback, optionally requiring generation ownership."""
if not session_key:
return None
entry = self._post_delivery_callbacks.get(session_key)
if entry is None:
return None
if isinstance(entry, tuple) and len(entry) == 2:
entry_generation, callback = entry
if generation is not None and int(entry_generation) != int(generation):
return None
self._post_delivery_callbacks.pop(session_key, None)
return callback if callable(callback) else None
if generation is not None:
return None
self._post_delivery_callbacks.pop(session_key, None)
return entry if callable(entry) else None
# ── Processing lifecycle hooks ──────────────────────────────────────────
# Subclasses override these to react to message processing events
# (e.g. Discord adds 👀/✅/❌ reactions).
@@ -1714,10 +1817,23 @@ class BasePlatformAdapter(ABC):
# Fall back to a new Event only if the entry was removed externally.
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
self._active_sessions[session_key] = interrupt_event
callback_generation = getattr(interrupt_event, "_hermes_run_generation", None)
# Start continuous typing indicator (refreshes every 2 seconds)
_thread_metadata = {"thread_id": event.source.thread_id} if event.source.thread_id else None
typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata))
_keep_typing_kwargs = {"metadata": _thread_metadata}
try:
_keep_typing_sig = inspect.signature(self._keep_typing)
except (TypeError, ValueError):
_keep_typing_sig = None
if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
_keep_typing_kwargs["stop_event"] = interrupt_event
typing_task = asyncio.create_task(
self._keep_typing(
event.source.chat_id,
**_keep_typing_kwargs,
)
)
try:
await self._run_processing_hook("on_processing_start", event)
@@ -1976,7 +2092,14 @@ class BasePlatformAdapter(ABC):
finally:
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
_post_cb = getattr(self, "_post_delivery_callbacks", {}).pop(session_key, None)
_callback_generation = callback_generation
if hasattr(self, "pop_post_delivery_callback"):
_post_cb = self.pop_post_delivery_callback(
session_key,
generation=_callback_generation,
)
else:
_post_cb = getattr(self, "_post_delivery_callbacks", {}).pop(session_key, None)
if callable(_post_cb):
try:
_post_cb()
@@ -2022,10 +2145,10 @@ class BasePlatformAdapter(ABC):
pass
# Leave _active_sessions[session_key] populated — the drain
# task's own lifecycle will clean it up.
return
# Clean up session tracking
if session_key in self._active_sessions:
del self._active_sessions[session_key]
else:
# Clean up session tracking
if session_key in self._active_sessions:
del self._active_sessions[session_key]
async def cancel_background_tasks(self) -> None:
"""Cancel any in-flight background message-processing tasks.
@@ -2033,12 +2156,26 @@ class BasePlatformAdapter(ABC):
Used during gateway shutdown/replacement so active sessions from the old
process do not keep running after adapters are being torn down.
"""
tasks = [task for task in self._background_tasks if not task.done()]
for task in tasks:
self._expected_cancelled_tasks.add(task)
task.cancel()
if tasks:
# Loop until no new tasks appear. Without this, a message
# arriving during the `await asyncio.gather` below would spawn
# a fresh _process_message_background task (added to
# self._background_tasks at line ~1668 via handle_message),
# and the _background_tasks.clear() at the end of this method
# would drop the reference — the task runs untracked against a
# disconnecting adapter, logs send-failures, and may linger
# until it completes on its own. Retrying the drain until the
# task set stabilizes closes the window.
MAX_DRAIN_ROUNDS = 5
for _ in range(MAX_DRAIN_ROUNDS):
tasks = [task for task in self._background_tasks if not task.done()]
if not tasks:
break
for task in tasks:
self._expected_cancelled_tasks.add(task)
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
# Loop: late-arrival tasks spawned during the gather above
# will be in self._background_tasks now. Re-check.
self._background_tasks.clear()
self._expected_cancelled_tasks.clear()
self._pending_messages.clear()
+51 -37
View File
@@ -498,6 +498,7 @@ class DiscordAdapter(BasePlatformAdapter):
self._allowed_role_ids: set = set() # For DISCORD_ALLOWED_ROLES filtering
# Voice channel state (per-guild)
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
self._voice_locks: Dict[int, asyncio.Lock] = {} # guild_id -> serialize join/leave
# Text batching: merge rapid successive messages (Telegram-style)
self._text_batch_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS", "0.6"))
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
@@ -636,6 +637,15 @@ class DiscordAdapter(BasePlatformAdapter):
@self._client.event
async def on_message(message: DiscordMessage):
# Block until _resolve_allowed_usernames has swapped
# any raw usernames in DISCORD_ALLOWED_USERS for numeric
# IDs (otherwise on_message's author.id lookup can miss).
if not adapter_self._ready_event.is_set():
try:
await asyncio.wait_for(adapter_self._ready_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
pass
# Dedup: Discord RESUME replays events after reconnects (#4777)
if adapter_self._dedup.is_duplicate(str(message.id)):
return
@@ -1071,6 +1081,8 @@ class DiscordAdapter(BasePlatformAdapter):
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""Edit a previously sent Discord message."""
if not self._client:
@@ -1237,51 +1249,53 @@ class DiscordAdapter(BasePlatformAdapter):
return False
guild_id = channel.guild.id
# Already connected in this guild?
existing = self._voice_clients.get(guild_id)
if existing and existing.is_connected():
if existing.channel.id == channel.id:
async with self._voice_locks.setdefault(guild_id, asyncio.Lock()):
# Already connected in this guild?
existing = self._voice_clients.get(guild_id)
if existing and existing.is_connected():
if existing.channel.id == channel.id:
self._reset_voice_timeout(guild_id)
return True
await existing.move_to(channel)
self._reset_voice_timeout(guild_id)
return True
await existing.move_to(channel)
vc = await channel.connect()
self._voice_clients[guild_id] = vc
self._reset_voice_timeout(guild_id)
# Start voice receiver (Phase 2: listen to users)
try:
receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids)
receiver.start()
self._voice_receivers[guild_id] = receiver
self._voice_listen_tasks[guild_id] = asyncio.ensure_future(
self._voice_listen_loop(guild_id)
)
except Exception as e:
logger.warning("Voice receiver failed to start: %s", e)
return True
vc = await channel.connect()
self._voice_clients[guild_id] = vc
self._reset_voice_timeout(guild_id)
# Start voice receiver (Phase 2: listen to users)
try:
receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids)
receiver.start()
self._voice_receivers[guild_id] = receiver
self._voice_listen_tasks[guild_id] = asyncio.ensure_future(
self._voice_listen_loop(guild_id)
)
except Exception as e:
logger.warning("Voice receiver failed to start: %s", e)
return True
async def leave_voice_channel(self, guild_id: int) -> None:
"""Disconnect from the voice channel in a guild."""
# Stop voice receiver first
receiver = self._voice_receivers.pop(guild_id, None)
if receiver:
receiver.stop()
listen_task = self._voice_listen_tasks.pop(guild_id, None)
if listen_task:
listen_task.cancel()
async with self._voice_locks.setdefault(guild_id, asyncio.Lock()):
# Stop voice receiver first
receiver = self._voice_receivers.pop(guild_id, None)
if receiver:
receiver.stop()
listen_task = self._voice_listen_tasks.pop(guild_id, None)
if listen_task:
listen_task.cancel()
vc = self._voice_clients.pop(guild_id, None)
if vc and vc.is_connected():
await vc.disconnect()
task = self._voice_timeout_tasks.pop(guild_id, None)
if task:
task.cancel()
self._voice_text_channels.pop(guild_id, None)
self._voice_sources.pop(guild_id, None)
vc = self._voice_clients.pop(guild_id, None)
if vc and vc.is_connected():
await vc.disconnect()
task = self._voice_timeout_tasks.pop(guild_id, None)
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
+240 -36
View File
@@ -8,7 +8,8 @@ Supports:
- Gateway allowlist integration via FEISHU_ALLOWED_USERS
- Persistent dedup state across restarts
- Per-chat serial message processing (matches openclaw createChatQueue)
- Persistent ACK emoji reaction on inbound messages
- Processing status reactions: Typing while working, removed on success,
swapped for CrossMark on failure
- Reaction events routed as synthetic text events (matches openclaw)
- Interactive card button-click events routed as synthetic COMMAND events
- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
@@ -29,6 +30,7 @@ import re
import threading
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
@@ -98,6 +100,7 @@ from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
cache_document_from_bytes,
@@ -119,6 +122,8 @@ _MARKDOWN_HINT_RE = re.compile(
re.MULTILINE,
)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
_MENTION_RE = re.compile(r"@_user_\d+")
_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE)
@@ -188,7 +193,17 @@ _APPROVAL_LABEL_MAP: Dict[str, str] = {
}
_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"
# Feishu reactions render as prominent badges, unlike Discord/Telegram's
# small footer emoji — a success badge on every message would add noise, so
# we only mark start (Typing) and failure (CrossMark); the reply itself is
# the success signal.
_FEISHU_REACTION_IN_PROGRESS = "Typing"
_FEISHU_REACTION_FAILURE = "CrossMark"
# Bound on the (message_id → reaction_id) handle cache. Happy-path entries
# drain on completion; the cap is a safeguard against unbounded growth from
# delete-failures, not a capacity plan.
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
# QR onboarding constants
_ONBOARD_ACCOUNTS_URLS = {
@@ -430,23 +445,66 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:
def _build_markdown_post_payload(content: str) -> str:
rows = _build_markdown_post_rows(content)
return json.dumps(
{
"zh_cn": {
"content": [
[
{
"tag": "md",
"text": content,
}
]
],
"content": rows,
}
},
ensure_ascii=False,
)
def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
"""Build Feishu post rows while isolating fenced code blocks.
Feishu's `md` renderer can swallow trailing content when a fenced code block
appears inside one large markdown element. Split the reply at real fence
lines so prose before/after the code block remains visible while code stays
in a dedicated row.
"""
if not content:
return [[{"tag": "md", "text": ""}]]
if "```" not in content:
return [[{"tag": "md", "text": content}]]
rows: List[List[Dict[str, str]]] = []
current: List[str] = []
in_code_block = False
def _flush_current() -> None:
nonlocal current
if not current:
return
segment = "\n".join(current)
if segment.strip():
rows.append([{"tag": "md", "text": segment}])
current = []
for raw_line in content.splitlines():
stripped_line = raw_line.strip()
is_fence = bool(
_MARKDOWN_FENCE_CLOSE_RE.match(stripped_line)
if in_code_block
else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line)
)
if is_fence:
if not in_code_block:
_flush_current()
current.append(raw_line)
in_code_block = not in_code_block
if not in_code_block:
_flush_current()
continue
current.append(raw_line)
_flush_current()
return rows or [[{"tag": "md", "text": content}]]
def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
resolved = _resolve_post_payload(payload)
if not resolved:
@@ -1096,6 +1154,9 @@ class FeishuAdapter(BasePlatformAdapter):
# Exec approval button state (approval_id → {session_key, message_id, chat_id})
self._approval_state: Dict[int, Dict[str, str]] = {}
self._approval_counter = itertools.count(1)
# Feishu reaction deletion requires the opaque reaction_id returned
# by create, so we cache it per message_id.
self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
self._load_seen_message_ids()
@staticmethod
@@ -1423,6 +1484,8 @@ class FeishuAdapter(BasePlatformAdapter):
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""Edit a previously sent Feishu text/post message."""
if not self._client:
@@ -1925,8 +1988,8 @@ class FeishuAdapter(BasePlatformAdapter):
if not message_id or self._is_duplicate(message_id):
logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id)
return
if getattr(sender, "sender_type", "") == "bot":
logger.debug("[Feishu] Dropping bot-originated event: %s", message_id)
if self._is_self_sent_bot_message(event):
logger.debug("[Feishu] Dropping self-sent bot event: %s", message_id)
return
chat_type = getattr(message, "chat_type", "p2p")
@@ -2003,12 +2066,12 @@ class FeishuAdapter(BasePlatformAdapter):
operator_type,
emoji_type,
)
# Only process reactions from real users. Ignore app/bot-generated reactions
# and Hermes' own ACK emoji to avoid feedback loops.
# Drop bot/app-origin reactions to break the feedback loop from our
# own lifecycle reactions. A human reacting with the same emoji (e.g.
# clicking Typing on a bot message) is still routed through.
loop = self._loop
if (
operator_type in {"bot", "app"}
or emoji_type == _FEISHU_ACK_EMOJI
or not message_id
or loop is None
or bool(getattr(loop, "is_closed", lambda: False)())
@@ -2232,33 +2295,35 @@ class FeishuAdapter(BasePlatformAdapter):
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
"""Dispatch a single event through the agent pipeline with per-chat serialization
and a persistent ACK emoji reaction before processing starts.
before handing the event off to the agent.
- Per-chat lock: ensures messages in the same chat are processed one at a time
(matches openclaw's createChatQueue serial queue behaviour).
- ACK indicator: adds a CHECK reaction to the triggering message before handing
off to the agent and leaves it in place as a receipt marker.
Per-chat lock ensures messages in the same chat are processed one at a
time (matches openclaw's createChatQueue serial queue behaviour).
"""
chat_id = getattr(event.source, "chat_id", "") or "" if event.source else ""
chat_lock = self._get_chat_lock(chat_id)
async with chat_lock:
message_id = event.message_id
if message_id:
await self._add_ack_reaction(message_id)
await self.handle_message(event)
async def _add_ack_reaction(self, message_id: str) -> Optional[str]:
"""Add a persistent ACK emoji reaction to signal the message was received."""
if not self._client or not message_id:
# =========================================================================
# Processing status reactions
# =========================================================================
def _reactions_enabled(self) -> bool:
return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no")
async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]:
"""Return the reaction_id on success, else None. The id is needed later for deletion."""
if not self._client or not message_id or not emoji_type:
return None
try:
from lark_oapi.api.im.v1 import ( # lazy import — keeps optional dep optional
from lark_oapi.api.im.v1 import (
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
)
body = (
CreateMessageReactionRequestBody.builder()
.reaction_type({"emoji_type": _FEISHU_ACK_EMOJI})
.reaction_type({"emoji_type": emoji_type})
.build()
)
request = (
@@ -2271,16 +2336,93 @@ class FeishuAdapter(BasePlatformAdapter):
if response and getattr(response, "success", lambda: False)():
data = getattr(response, "data", None)
return getattr(data, "reaction_id", None)
logger.warning(
"[Feishu] Failed to add ack reaction to %s: code=%s msg=%s",
logger.debug(
"[Feishu] Add reaction %s on %s rejected: code=%s msg=%s",
emoji_type,
message_id,
getattr(response, "code", None),
getattr(response, "msg", None),
)
except Exception:
logger.warning("[Feishu] Failed to add ack reaction to %s", message_id, exc_info=True)
logger.warning(
"[Feishu] Add reaction %s on %s raised",
emoji_type,
message_id,
exc_info=True,
)
return None
async def _remove_reaction(self, message_id: str, reaction_id: str) -> bool:
if not self._client or not message_id or not reaction_id:
return False
try:
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
request = (
DeleteMessageReactionRequest.builder()
.message_id(message_id)
.reaction_id(reaction_id)
.build()
)
response = await asyncio.to_thread(self._client.im.v1.message_reaction.delete, request)
if response and getattr(response, "success", lambda: False)():
return True
logger.debug(
"[Feishu] Remove reaction %s on %s rejected: code=%s msg=%s",
reaction_id,
message_id,
getattr(response, "code", None),
getattr(response, "msg", None),
)
except Exception:
logger.warning(
"[Feishu] Remove reaction %s on %s raised",
reaction_id,
message_id,
exc_info=True,
)
return False
def _remember_processing_reaction(self, message_id: str, reaction_id: str) -> None:
cache = self._pending_processing_reactions
cache[message_id] = reaction_id
cache.move_to_end(message_id)
while len(cache) > _FEISHU_PROCESSING_REACTION_CACHE_SIZE:
cache.popitem(last=False)
def _pop_processing_reaction(self, message_id: str) -> Optional[str]:
return self._pending_processing_reactions.pop(message_id, None)
async def on_processing_start(self, event: MessageEvent) -> None:
if not self._reactions_enabled():
return
message_id = event.message_id
if not message_id or message_id in self._pending_processing_reactions:
return
reaction_id = await self._add_reaction(message_id, _FEISHU_REACTION_IN_PROGRESS)
if reaction_id:
self._remember_processing_reaction(message_id, reaction_id)
async def on_processing_complete(
self, event: MessageEvent, outcome: ProcessingOutcome
) -> None:
if not self._reactions_enabled():
return
message_id = event.message_id
if not message_id:
return
start_reaction_id = self._pending_processing_reactions.get(message_id)
if start_reaction_id:
if not await self._remove_reaction(message_id, start_reaction_id):
# Don't stack a second badge on top of a Typing we couldn't
# remove — UI would read as both "working" and "done/failed"
# simultaneously. Keep the handle so LRU eventually evicts it.
return
self._pop_processing_reaction(message_id)
if outcome is ProcessingOutcome.FAILURE:
await self._add_reaction(message_id, _FEISHU_REACTION_FAILURE)
# =========================================================================
# Webhook server and security
# =========================================================================
@@ -3249,6 +3391,23 @@ class FeishuAdapter(BasePlatformAdapter):
return self._post_mentions_bot(normalized.mentioned_ids)
return False
def _is_self_sent_bot_message(self, event: Any) -> bool:
"""Return True only for Feishu events emitted by this Hermes bot."""
sender = getattr(event, "sender", None)
sender_type = str(getattr(sender, "sender_type", "") or "").strip().lower()
if sender_type not in {"bot", "app"}:
return False
sender_id = getattr(sender, "sender_id", None)
sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip()
sender_user_id = str(getattr(sender_id, "user_id", "") or "").strip()
if self._bot_open_id and sender_open_id == self._bot_open_id:
return True
if self._bot_user_id and sender_user_id == self._bot_user_id:
return True
return False
def _message_mentions_bot(self, mentions: List[Any]) -> bool:
"""Check whether any mention targets the configured or inferred bot identity."""
for mention in mentions:
@@ -3276,10 +3435,55 @@ class FeishuAdapter(BasePlatformAdapter):
return False
async def _hydrate_bot_identity(self) -> None:
"""Best-effort discovery of bot identity for precise group mention gating."""
"""Best-effort discovery of bot identity for precise group mention gating
and self-sent bot event filtering.
Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
(no extra scopes required beyond the tenant access token). Falls back to
the application info endpoint for ``_bot_name`` only when the first probe
doesn't return it. Each field is hydrated independently — a value already
supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID /
FEISHU_BOT_NAME) is preserved and skips its probe.
"""
if not self._client:
return
if any((self._bot_open_id, self._bot_user_id, self._bot_name)):
if self._bot_open_id and self._bot_name:
# Everything the self-send filter and precise mention gate need is
# already in place; nothing to probe.
return
# Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
# extra scopes required. This is the same endpoint the onboarding wizard
# uses via probe_bot().
if not self._bot_open_id or not self._bot_name:
try:
resp = await asyncio.to_thread(
self._client.request,
method="GET",
url="/open-apis/bot/v3/info",
body=None,
raw_response=True,
)
content = getattr(resp, "content", None)
if content:
payload = json.loads(content)
parsed = _parse_bot_response(payload) or {}
open_id = (parsed.get("bot_open_id") or "").strip()
bot_name = (parsed.get("bot_name") or "").strip()
if open_id and not self._bot_open_id:
self._bot_open_id = open_id
if bot_name and not self._bot_name:
self._bot_name = bot_name
except Exception:
logger.debug(
"[Feishu] /bot/v3/info probe failed during hydration",
exc_info=True,
)
# Fallback probe for _bot_name only: application info endpoint. Needs
# admin:app.info:readonly or application:application:self_manage scope,
# so it's best-effort.
if self._bot_name:
return
try:
request = self._build_get_application_request(app_id=self._app_id, lang="en_us")
@@ -3288,17 +3492,17 @@ class FeishuAdapter(BasePlatformAdapter):
code = getattr(response, "code", None)
if code == 99991672:
logger.warning(
"[Feishu] Unable to hydrate bot identity from application info. "
"[Feishu] Unable to hydrate bot name from application info. "
"Grant admin:app.info:readonly or application:application:self_manage "
"so group @mention gating can resolve the bot name precisely."
)
return
app = getattr(getattr(response, "data", None), "app", None)
app_name = (getattr(app, "app_name", None) or "").strip()
if app_name:
if app_name and not self._bot_name:
self._bot_name = app_name
except Exception:
logger.debug("[Feishu] Failed to hydrate bot identity", exc_info=True)
logger.debug("[Feishu] Failed to hydrate bot name from application info", exc_info=True)
# =========================================================================
# Deduplication — seen message ID cache (persistent)
+1 -1
View File
@@ -825,7 +825,7 @@ class MatrixAdapter(BasePlatformAdapter):
async def edit_message(
self, chat_id: str, message_id: str, content: str
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
) -> SendResult:
"""Edit an existing message (via m.replace)."""
+1 -1
View File
@@ -304,7 +304,7 @@ class MattermostAdapter(BasePlatformAdapter):
)
async def edit_message(
self, chat_id: str, message_id: str, content: str
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
) -> SendResult:
"""Edit an existing post."""
formatted = self.format_message(content)
+114 -18
View File
@@ -18,6 +18,7 @@ import logging
import os
import random
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Any
@@ -127,6 +128,27 @@ def _render_mentions(text: str, mentions: list) -> str:
return text
def _is_signal_service_id(value: str) -> bool:
"""Return True if *value* already looks like a Signal service identifier."""
if not value:
return False
if value.startswith("PNI:") or value.startswith("u:"):
return True
try:
uuid.UUID(value)
return True
except (ValueError, AttributeError, TypeError):
return False
def _looks_like_e164_number(value: str) -> bool:
"""Return True for a plausible E.164 phone number."""
if not value or not value.startswith("+"):
return False
digits = value[1:]
return digits.isdigit() and 7 <= len(digits) <= 15
def check_signal_requirements() -> bool:
"""Check if Signal is configured (has URL and account)."""
return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT"))
@@ -179,6 +201,12 @@ class SignalAdapter(BasePlatformAdapter):
# in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds)
self._recent_sent_timestamps: set = set()
self._max_recent_timestamps = 50
# Signal increasingly exposes ACI/PNI UUIDs as stable recipient IDs.
# Keep a best-effort mapping so outbound sends can upgrade from a
# phone number to the corresponding UUID when signal-cli prefers it.
self._recipient_uuid_by_number: Dict[str, str] = {}
self._recipient_number_by_uuid: Dict[str, str] = {}
self._recipient_cache_lock = asyncio.Lock()
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
self.http_url, redact_phone(self.account),
@@ -195,31 +223,40 @@ class SignalAdapter(BasePlatformAdapter):
return False
# Acquire scoped lock to prevent duplicate Signal listeners for the same phone
lock_acquired = False
try:
if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'):
return False
lock_acquired = True
except Exception as e:
logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e)
self.client = httpx.AsyncClient(timeout=30.0)
# Health check — verify signal-cli daemon is reachable
try:
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
if resp.status_code != 200:
logger.error("Signal: health check failed (status %d)", resp.status_code)
# Health check — verify signal-cli daemon is reachable
try:
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
if resp.status_code != 200:
logger.error("Signal: health check failed (status %d)", resp.status_code)
return False
except Exception as e:
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
return False
except Exception as e:
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
return False
self._running = True
self._last_sse_activity = time.time()
self._sse_task = asyncio.create_task(self._sse_listener())
self._health_monitor_task = asyncio.create_task(self._health_monitor())
self._running = True
self._last_sse_activity = time.time()
self._sse_task = asyncio.create_task(self._sse_listener())
self._health_monitor_task = asyncio.create_task(self._health_monitor())
logger.info("Signal: connected to %s", self.http_url)
return True
logger.info("Signal: connected to %s", self.http_url)
return True
finally:
if not self._running:
if self.client:
await self.client.aclose()
self.client = None
if lock_acquired:
self._release_platform_lock()
async def disconnect(self) -> None:
"""Stop SSE listener and clean up."""
@@ -400,6 +437,7 @@ class SignalAdapter(BasePlatformAdapter):
)
sender_name = envelope_data.get("sourceName", "")
sender_uuid = envelope_data.get("sourceUuid", "")
self._remember_recipient_identifiers(sender, sender_uuid)
if not sender:
logger.debug("Signal: ignoring envelope with no sender")
@@ -518,6 +556,64 @@ class SignalAdapter(BasePlatformAdapter):
await self.handle_message(event)
def _remember_recipient_identifiers(self, number: Optional[str], service_id: Optional[str]) -> None:
"""Cache any number↔UUID mapping observed from Signal envelopes."""
if not number or not service_id or not _is_signal_service_id(service_id):
return
self._recipient_uuid_by_number[number] = service_id
self._recipient_number_by_uuid[service_id] = number
def _extract_contact_uuid(self, contact: Any, phone_number: str) -> Optional[str]:
"""Best-effort extraction of a Signal service ID from listContacts output."""
if not isinstance(contact, dict):
return None
number = contact.get("number")
recipient = contact.get("recipient")
service_id = contact.get("uuid") or contact.get("serviceId")
if not service_id:
profile = contact.get("profile")
if isinstance(profile, dict):
service_id = profile.get("serviceId") or profile.get("uuid")
if service_id and _is_signal_service_id(service_id):
matches_number = number == phone_number or recipient == phone_number
if matches_number:
return service_id
return None
async def _resolve_recipient(self, chat_id: str) -> str:
"""Return the preferred Signal recipient identifier for a direct chat."""
if (
not chat_id
or chat_id.startswith("group:")
or _is_signal_service_id(chat_id)
or not _looks_like_e164_number(chat_id)
):
return chat_id
cached = self._recipient_uuid_by_number.get(chat_id)
if cached:
return cached
async with self._recipient_cache_lock:
cached = self._recipient_uuid_by_number.get(chat_id)
if cached:
return cached
contacts = await self._rpc("listContacts", {
"account": self.account,
"allRecipients": True,
})
if isinstance(contacts, list):
for contact in contacts:
number = contact.get("number") if isinstance(contact, dict) else None
service_id = self._extract_contact_uuid(contact, chat_id)
if number and service_id:
self._remember_recipient_identifiers(number, service_id)
return self._recipient_uuid_by_number.get(chat_id, chat_id)
# ------------------------------------------------------------------
# Attachment Handling
# ------------------------------------------------------------------
@@ -633,7 +729,7 @@ class SignalAdapter(BasePlatformAdapter):
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [chat_id]
params["recipient"] = [await self._resolve_recipient(chat_id)]
result = await self._rpc("send", params)
@@ -684,7 +780,7 @@ class SignalAdapter(BasePlatformAdapter):
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [chat_id]
params["recipient"] = [await self._resolve_recipient(chat_id)]
fails = self._typing_failures.get(chat_id, 0)
result = await self._rpc(
@@ -745,7 +841,7 @@ class SignalAdapter(BasePlatformAdapter):
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [chat_id]
params["recipient"] = [await self._resolve_recipient(chat_id)]
result = await self._rpc("send", params)
if result is not None:
@@ -784,7 +880,7 @@ class SignalAdapter(BasePlatformAdapter):
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [chat_id]
params["recipient"] = [await self._resolve_recipient(chat_id)]
result = await self._rpc("send", params)
if result is not None:
+7
View File
@@ -150,9 +150,11 @@ class SlackAdapter(BasePlatformAdapter):
except Exception as e:
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
lock_acquired = False
try:
if not self._acquire_platform_lock('slack-app-token', app_token, 'Slack app token'):
return False
lock_acquired = True
# First token is the primary — used for AsyncApp / Socket Mode
primary_token = bot_tokens[0]
@@ -228,6 +230,9 @@ class SlackAdapter(BasePlatformAdapter):
except Exception as e: # pragma: no cover - defensive logging
logger.error("[Slack] Connection failed: %s", e, exc_info=True)
return False
finally:
if lock_acquired and not self._running:
self._release_platform_lock()
async def disconnect(self) -> None:
"""Disconnect from Slack."""
@@ -316,6 +321,8 @@ class SlackAdapter(BasePlatformAdapter):
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""Edit a previously sent Slack message."""
if not self._app:
+82 -10
View File
@@ -11,6 +11,7 @@ import asyncio
import json
import logging
import os
import tempfile
import html as _html
import re
from typing import Dict, List, Optional, Any
@@ -70,8 +71,10 @@ from gateway.platforms.base import (
SendResult,
cache_image_from_bytes,
cache_audio_from_bytes,
cache_video_from_bytes,
cache_document_from_bytes,
resolve_proxy_url,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
utf16_len,
_prefix_within_utf16_limit,
@@ -534,8 +537,23 @@ class TelegramAdapter(BasePlatformAdapter):
break
if changed:
with open(config_path, "w") as f:
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
fd, tmp_path = tempfile.mkstemp(
dir=str(config_path.parent),
suffix=".tmp",
prefix=".config_",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, config_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
logger.info(
"[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
self.name, thread_id, topic_name,
@@ -1081,6 +1099,8 @@ class TelegramAdapter(BasePlatformAdapter):
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""Edit a previously sent Telegram message."""
if not self._bot:
@@ -1657,6 +1677,21 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception as exc:
logger.error("Failed to write update response from callback: %s", exc)
def _missing_media_path_error(self, label: str, path: str) -> str:
"""Build an actionable file-not-found error for gateway MEDIA delivery.
Paths like /workspace/... or /output/... often only exist inside the
Docker sandbox, while the gateway process runs on the host.
"""
error = f"{label} file not found: {path}"
if path.startswith(("/workspace/", "/output/", "/outputs/")):
error += (
" (path may only exist inside the Docker sandbox. "
"Bind-mount a host directory and emit the host-visible "
"path in MEDIA: for gateway file delivery.)"
)
return error
async def send_voice(
self,
chat_id: str,
@@ -1673,7 +1708,7 @@ class TelegramAdapter(BasePlatformAdapter):
try:
import os
if not os.path.exists(audio_path):
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
with open(audio_path, "rb") as audio_file:
# .ogg files -> send as voice (round playable bubble)
@@ -1722,7 +1757,7 @@ class TelegramAdapter(BasePlatformAdapter):
try:
import os
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
return SendResult(success=False, error=self._missing_media_path_error("Image", image_path))
_thread = self._metadata_thread_id(metadata)
with open(image_path, "rb") as image_file:
@@ -1759,7 +1794,7 @@ class TelegramAdapter(BasePlatformAdapter):
try:
if not os.path.exists(file_path):
return SendResult(success=False, error=f"File not found: {file_path}")
return SendResult(success=False, error=self._missing_media_path_error("File", file_path))
display_name = file_name or os.path.basename(file_path)
_thread = self._metadata_thread_id(metadata)
@@ -1793,7 +1828,7 @@ class TelegramAdapter(BasePlatformAdapter):
try:
if not os.path.exists(video_path):
return SendResult(success=False, error=f"Video file not found: {video_path}")
return SendResult(success=False, error=self._missing_media_path_error("Video", video_path))
_thread = self._metadata_thread_id(metadata)
with open(video_path, "rb") as f:
@@ -2241,22 +2276,27 @@ class TelegramAdapter(BasePlatformAdapter):
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
bot_id = getattr(self._bot, "id", None)
expected = f"@{bot_username}" if bot_username else None
def _iter_sources():
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
# Telegram parses mentions server-side and emits MessageEntity objects
# (type=mention for @username, type=text_mention for @FirstName targeting
# a user without a public username). Only those entities are authoritative —
# raw substring matches like "foo@hermes_bot.example" are not mentions
# (bug #12545). Entities also correctly handle @handles inside URLs, code
# blocks, and quoted text, where a regex scan would over-match.
for source_text, entities in _iter_sources():
if bot_username and f"@{bot_username}" in source_text.lower():
return True
for entity in entities:
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
if entity_type == "mention" and bot_username:
if entity_type == "mention" and expected:
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
if offset < 0 or length <= 0:
continue
if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
if source_text[offset:offset + length].strip().lower() == expected:
return True
elif entity_type == "text_mention":
user = getattr(entity, "user", None)
@@ -2590,6 +2630,23 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception as e:
logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True)
elif msg.video:
try:
file_obj = await msg.video.get_file()
video_bytes = await file_obj.download_as_bytearray()
ext = ".mp4"
if getattr(file_obj, "file_path", None):
for candidate in SUPPORTED_VIDEO_TYPES:
if file_obj.file_path.lower().endswith(candidate):
ext = candidate
break
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")]
logger.info("[Telegram] Cached user video at %s", cached_path)
except Exception as e:
logger.warning("[Telegram] Failed to cache video: %s", e, exc_info=True)
# Download document files to cache for agent processing
elif msg.document:
doc = msg.document
@@ -2606,6 +2663,21 @@ class TelegramAdapter(BasePlatformAdapter):
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(doc.mime_type, "")
if not ext and doc.mime_type:
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
ext = video_mime_to_ext.get(doc.mime_type, "")
if ext in SUPPORTED_VIDEO_TYPES:
file_obj = await doc.get_file()
video_bytes = await file_obj.download_as_bytearray()
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]
event.message_type = MessageType.VIDEO
logger.info("[Telegram] Cached user video document at %s", cached_path)
await self.handle_message(event)
return
# Check if supported
if ext not in SUPPORTED_DOCUMENT_TYPES:
supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
+115 -12
View File
@@ -13,6 +13,10 @@ Each route defines:
- skills: optional list of skills to load for the agent
- deliver: where to send the response (github_comment, telegram, etc.)
- deliver_extra: additional delivery config (repo, pr_number, chat_id)
- deliver_only: if true, skip the agent the rendered prompt IS the
message that gets delivered. Use for external push notifications
(Supabase, monitoring alerts, inter-agent pings) where zero LLM cost
and sub-second delivery matter more than agent reasoning.
Security:
- HMAC secret is required per route (validated at startup)
@@ -122,6 +126,19 @@ class WebhookAdapter(BasePlatformAdapter):
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
)
# deliver_only routes bypass the agent — the POST body becomes a
# direct push notification via the configured delivery target.
# Validate up-front so misconfiguration surfaces at startup rather
# than on the first webhook POST.
if route.get("deliver_only"):
deliver = route.get("deliver", "log")
if not deliver or deliver == "log":
raise ValueError(
f"[webhook] Route '{name}' has deliver_only=true but "
f"deliver is '{deliver}'. Direct delivery requires a "
f"real target (telegram, discord, slack, github_comment, etc.)."
)
app = web.Application()
app.router.add_get("/health", self._handle_health)
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
@@ -296,24 +313,14 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": "Payload too large"}, status=413
)
# ── Rate limiting ────────────────────────────────────────
now = time.time()
window = self._rate_counts.setdefault(route_name, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rate_limit:
return web.json_response(
{"error": "Rate limit exceeded"}, status=429
)
window.append(now)
# Read body
# Read body (must be done before any validation)
try:
raw_body = await request.read()
except Exception as e:
logger.error("[webhook] Failed to read body: %s", e)
return web.json_response({"error": "Bad request"}, status=400)
# Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode)
# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
secret = route_config.get("secret", self._global_secret)
if secret and secret != _INSECURE_NO_AUTH:
if not self._validate_signature(request, raw_body, secret):
@@ -324,6 +331,16 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": "Invalid signature"}, status=401
)
# ── Rate limiting (after auth) ───────────────────────────
now = time.time()
window = self._rate_counts.setdefault(route_name, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rate_limit:
return web.json_response(
{"error": "Rate limit exceeded"}, status=429
)
window.append(now)
# Parse payload
try:
payload = json.loads(raw_body)
@@ -419,6 +436,64 @@ class WebhookAdapter(BasePlatformAdapter):
)
self._seen_deliveries[delivery_id] = now
# ── Direct delivery mode (deliver_only) ─────────────────
# Skip the agent entirely — the rendered prompt IS the message we
# deliver. Use case: external services (Supabase, monitoring,
# cron jobs, other agents) that need to push a plain notification
# to a user's chat with zero LLM cost. Reuses the same HMAC auth,
# rate limiting, idempotency, and template rendering as agent mode.
if route_config.get("deliver_only"):
delivery = {
"deliver": route_config.get("deliver", "log"),
"deliver_extra": self._render_delivery_extra(
route_config.get("deliver_extra", {}), payload
),
"payload": payload,
}
logger.info(
"[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s",
event_type,
route_name,
delivery["deliver"],
len(prompt),
delivery_id,
)
try:
result = await self._direct_deliver(prompt, delivery)
except Exception:
logger.exception(
"[webhook] direct-deliver failed route=%s delivery=%s",
route_name,
delivery_id,
)
return web.json_response(
{"status": "error", "error": "Delivery failed", "delivery_id": delivery_id},
status=502,
)
if result.success:
return web.json_response(
{
"status": "delivered",
"route": route_name,
"target": delivery["deliver"],
"delivery_id": delivery_id,
},
status=200,
)
# Delivery attempted but target rejected it — surface as 502
# with a generic error (don't leak adapter-level detail).
logger.warning(
"[webhook] direct-deliver target rejected route=%s target=%s error=%s",
route_name,
delivery["deliver"],
result.error,
)
return web.json_response(
{"status": "error", "error": "Delivery failed", "delivery_id": delivery_id},
status=502,
)
# Use delivery_id in session key so concurrent webhooks on the
# same route get independent agent runs (not queued/interrupted).
session_chat_id = f"webhook:{route_name}:{delivery_id}"
@@ -572,6 +647,34 @@ class WebhookAdapter(BasePlatformAdapter):
# Response delivery
# ------------------------------------------------------------------
async def _direct_deliver(
self, content: str, delivery: dict
) -> SendResult:
"""Deliver *content* directly without invoking the agent.
Used by ``deliver_only`` routes: the rendered template becomes the
literal message body, and we dispatch to the same delivery helpers
that the agent-mode ``send()`` flow uses. All target types that
work in agent mode work here Telegram, Discord, Slack, GitHub
PR comments, etc.
"""
deliver_type = delivery.get("deliver", "log")
if deliver_type == "log":
# Shouldn't reach here — startup validation rejects deliver_only
# with deliver=log — but guard defensively.
logger.info("[webhook] direct-deliver log-only: %s", content[:200])
return SendResult(success=True)
if deliver_type == "github_comment":
return await self._deliver_github_comment(content, delivery)
# Fall through to the cross-platform dispatcher, which validates the
# target name and routes via the gateway runner.
return await self._deliver_cross_platform(
deliver_type, content, delivery
)
async def _deliver_github_comment(
self, content: str, delivery: dict
) -> SendResult:
+40 -22
View File
@@ -289,33 +289,35 @@ class WhatsAppAdapter(BasePlatformAdapter):
logger.info("[%s] Bridge found at %s", self.name, bridge_path)
# Acquire scoped lock to prevent duplicate sessions
lock_acquired = False
try:
if not self._acquire_platform_lock('whatsapp-session', str(self._session_path), 'WhatsApp session'):
return False
lock_acquired = True
except Exception as e:
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
# Auto-install npm dependencies if node_modules doesn't exist
bridge_dir = bridge_path.parent
if not (bridge_dir / "node_modules").exists():
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
try:
install_result = subprocess.run(
["npm", "install", "--silent"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=60,
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
return False
print(f"[{self.name}] Dependencies installed")
except Exception as e:
print(f"[{self.name}] Failed to install dependencies: {e}")
return False
try:
# Auto-install npm dependencies if node_modules doesn't exist
bridge_dir = bridge_path.parent
if not (bridge_dir / "node_modules").exists():
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
try:
install_result = subprocess.run(
["npm", "install", "--silent"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=60,
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
return False
print(f"[{self.name}] Dependencies installed")
except Exception as e:
print(f"[{self.name}] Failed to install dependencies: {e}")
return False
# Ensure session directory exists
self._session_path.mkdir(parents=True, exist_ok=True)
@@ -452,10 +454,13 @@ class WhatsAppAdapter(BasePlatformAdapter):
return True
except Exception as e:
self._release_platform_lock()
logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
self._close_bridge_log()
return False
finally:
if not self._running:
if lock_acquired:
self._release_platform_lock()
self._close_bridge_log()
def _close_bridge_log(self) -> None:
"""Close the bridge log file handle if open."""
@@ -655,6 +660,8 @@ class WhatsAppAdapter(BasePlatformAdapter):
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""Edit a previously sent message via the WhatsApp bridge."""
if not self._running or not self._http_session:
@@ -766,6 +773,17 @@ class WhatsAppAdapter(BasePlatformAdapter):
"""Send a video natively via bridge — plays inline in WhatsApp."""
return await self._send_media_to_bridge(chat_id, video_path, "video", caption)
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send an audio file as a WhatsApp voice message via bridge."""
return await self._send_media_to_bridge(chat_id, audio_path, "audio", caption)
async def send_document(
self,
chat_id: str,
+478 -83
View File
@@ -96,6 +96,10 @@ from hermes_cli.env_loader import load_hermes_dotenv
_env_path = _hermes_home / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}
# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings — overrides .env.
_config_path = _hermes_home / 'config.yaml'
@@ -398,6 +402,33 @@ def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None:
return adapter.get_pending_message(session_key)
_INTERRUPT_REASON_STOP = "Stop requested"
_INTERRUPT_REASON_RESET = "Session reset requested"
_INTERRUPT_REASON_TIMEOUT = "Execution timed out (inactivity)"
_INTERRUPT_REASON_SSE_DISCONNECT = "SSE client disconnected"
_INTERRUPT_REASON_GATEWAY_SHUTDOWN = "Gateway shutting down"
_INTERRUPT_REASON_GATEWAY_RESTART = "Gateway restarting"
_CONTROL_INTERRUPT_MESSAGES = frozenset(
{
_INTERRUPT_REASON_STOP.lower(),
_INTERRUPT_REASON_RESET.lower(),
_INTERRUPT_REASON_TIMEOUT.lower(),
_INTERRUPT_REASON_SSE_DISCONNECT.lower(),
_INTERRUPT_REASON_GATEWAY_SHUTDOWN.lower(),
_INTERRUPT_REASON_GATEWAY_RESTART.lower(),
}
)
def _is_control_interrupt_message(message: Optional[str]) -> bool:
"""Return True when an interrupt message is internal control flow."""
if not message:
return False
normalized = " ".join(str(message).strip().split()).lower()
return normalized in _CONTROL_INTERRUPT_MESSAGES
def _check_unavailable_skill(command_name: str) -> str | None:
"""Check if a command matches a known-but-inactive skill.
@@ -585,6 +616,7 @@ class GatewayRunner:
def __init__(self, config: Optional[GatewayConfig] = None):
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
self._warn_if_docker_media_delivery_is_risky()
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@@ -597,7 +629,6 @@ class GatewayRunner:
self._restart_drain_timeout = self._load_restart_drain_timeout()
self._provider_routing = self._load_provider_routing()
self._fallback_model = self._load_fallback_model()
self._smart_model_routing = self._load_smart_model_routing()
# Wire process registry into session store for reset protection
from tools.process_registry import process_registry
@@ -625,6 +656,7 @@ class GatewayRunner:
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
self._session_run_generation: Dict[str, int] = {}
# Cache AIAgent instances per session to preserve prompt caching.
# Without this, a new AIAgent is created per message, rebuilding the
@@ -691,6 +723,53 @@ class GatewayRunner:
self._background_tasks: set = set()
def _warn_if_docker_media_delivery_is_risky(self) -> None:
"""Warn when Docker-backed gateways lack an explicit export mount.
MEDIA delivery happens in the gateway process, so paths emitted by the model
must be readable from the host. A plain container-local path like
`/workspace/report.txt` or `/output/report.txt` often exists only inside
Docker, so users commonly need a dedicated export mount such as
`host-dir:/output`.
"""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return
connected = self.config.get_connected_platforms()
messaging_platforms = [p for p in connected if p not in {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK}]
if not messaging_platforms:
return
raw_volumes = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip()
volumes: List[str] = []
if raw_volumes:
try:
parsed = json.loads(raw_volumes)
if isinstance(parsed, list):
volumes = [str(v) for v in parsed if isinstance(v, str)]
except Exception:
logger.debug("Could not parse TERMINAL_DOCKER_VOLUMES for gateway media warning", exc_info=True)
has_explicit_output_mount = False
for spec in volumes:
match = _DOCKER_VOLUME_SPEC_RE.match(spec)
if not match:
continue
container_path = match.group("container")
if container_path in _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS:
has_explicit_output_mount = True
break
if has_explicit_output_mount:
return
logger.warning(
"Docker backend is enabled for the messaging gateway but no explicit host-visible "
"output mount (for example '/home/user/.hermes/cache/documents:/output') is configured. "
"This is fine if the model already emits host-visible paths, but MEDIA file delivery can fail "
"for container-local paths like '/workspace/...' or '/output/...'."
)
# -- Setup skill availability ----------------------------------------
@@ -707,6 +786,10 @@ class GatewayRunner:
_VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
def _voice_key(self, platform: Platform, chat_id: str) -> str:
"""Return a platform-namespaced key for voice mode state."""
return f"{platform.value}:{chat_id}"
def _load_voice_modes(self) -> Dict[str, str]:
try:
data = json.loads(self._VOICE_MODE_PATH.read_text())
@@ -717,11 +800,21 @@ class GatewayRunner:
return {}
valid_modes = {"off", "voice_only", "all"}
return {
str(chat_id): mode
for chat_id, mode in data.items()
if mode in valid_modes
}
result = {}
for chat_id, mode in data.items():
if mode not in valid_modes:
continue
key = str(chat_id)
# Skip legacy unprefixed keys (warn and skip)
if ":" not in key:
logger.warning(
"Skipping legacy unprefixed voice mode key %r during migration. "
"Re-enable voice mode on that chat to rebuild the prefixed key.",
key,
)
continue
result[key] = mode
return result
def _save_voice_modes(self) -> None:
try:
@@ -747,9 +840,14 @@ class GatewayRunner:
disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
if not isinstance(disabled_chats, set):
return
platform = getattr(adapter, "platform", None)
if not isinstance(platform, Platform):
return
disabled_chats.clear()
prefix = f"{platform.value}:"
disabled_chats.update(
chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
key[len(prefix):] for key, mode in self._voice_mode.items()
if mode == "off" and key.startswith(prefix)
)
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
@@ -1002,11 +1100,16 @@ class GatewayRunner:
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
from agent.smart_model_routing import resolve_turn_route
"""Build the effective model/runtime config for a single turn.
Always uses the session's primary model/provider. If `/fast` is
enabled and the model supports Priority Processing / Anthropic fast
mode, attach `request_overrides` so the API call is marked
accordingly.
"""
from hermes_cli.models import resolve_fast_mode_overrides
primary = {
"model": model,
runtime = {
"api_key": runtime_kwargs.get("api_key"),
"base_url": runtime_kwargs.get("base_url"),
"provider": runtime_kwargs.get("provider"),
@@ -1015,7 +1118,18 @@ class GatewayRunner:
"args": list(runtime_kwargs.get("args") or []),
"credential_pool": runtime_kwargs.get("credential_pool"),
}
route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
route = {
"model": model,
"runtime": runtime,
"signature": (
model,
runtime["provider"],
runtime["base_url"],
runtime["api_mode"],
runtime["command"],
tuple(runtime["args"]),
),
}
service_tier = getattr(self, "_service_tier", None)
if not service_tier:
@@ -1023,7 +1137,7 @@ class GatewayRunner:
return route
try:
overrides = resolve_fast_mode_overrides(route.get("model"))
overrides = resolve_fast_mode_overrides(route["model"])
except Exception:
overrides = None
route["request_overrides"] = overrides
@@ -1381,20 +1495,6 @@ class GatewayRunner:
pass
return None
@staticmethod
def _load_smart_model_routing() -> dict:
"""Load optional smart cheap-vs-strong model routing config."""
try:
import yaml as _y
cfg_path = _hermes_home / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as _f:
cfg = _y.safe_load(_f) or {}
return cfg.get("smart_model_routing", {}) or {}
except Exception:
pass
return {}
def _snapshot_running_agents(self) -> Dict[str, Any]:
return {
session_key: agent
@@ -1567,12 +1667,32 @@ class GatewayRunner:
notified: set = set()
for session_key in active:
# Parse platform + chat_id from the session key.
_parsed = _parse_session_key(session_key)
if not _parsed:
continue
platform_str = _parsed["platform"]
chat_id = _parsed["chat_id"]
source = None
try:
if getattr(self, "session_store", None) is not None:
self.session_store._ensure_loaded()
entry = self.session_store._entries.get(session_key)
source = getattr(entry, "origin", None) if entry else None
except Exception as e:
logger.debug(
"Failed to load session origin for shutdown notification %s: %s",
session_key,
e,
)
if source is not None:
platform_str = source.platform.value
chat_id = source.chat_id
thread_id = source.thread_id
else:
# Fall back to parsing the session key when no persisted
# origin is available (legacy sessions/tests).
_parsed = _parse_session_key(session_key)
if not _parsed:
continue
platform_str = _parsed["platform"]
chat_id = _parsed["chat_id"]
thread_id = _parsed.get("thread_id")
# Deduplicate: one notification per chat, even if multiple
# sessions (different users/threads) share the same chat.
@@ -1588,7 +1708,6 @@ class GatewayRunner:
# Include thread_id if present so the message lands in the
# correct forum topic / thread.
thread_id = _parsed.get("thread_id")
metadata = {"thread_id": thread_id} if thread_id else None
await adapter.send(chat_id, msg, metadata=metadata)
@@ -2441,7 +2560,7 @@ class GatewayRunner:
_sk[:20], _e,
)
self._interrupt_running_agents(
"Gateway restarting" if self._restart_requested else "Gateway shutting down"
_INTERRUPT_REASON_GATEWAY_RESTART if self._restart_requested else _INTERRUPT_REASON_GATEWAY_SHUTDOWN
)
interrupt_deadline = asyncio.get_running_loop().time() + 5.0
while self._running_agents and asyncio.get_running_loop().time() < interrupt_deadline:
@@ -2862,10 +2981,59 @@ class GatewayRunner:
return bool(check_ids & allowed_ids)
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
"""Return how unauthorized DMs should be handled for a platform."""
"""Return how unauthorized DMs should be handled for a platform.
Resolution order:
1. Explicit per-platform ``unauthorized_dm_behavior`` in config always wins.
2. Explicit global ``unauthorized_dm_behavior`` in config wins when no per-platform.
3. When an allowlist (``PLATFORM_ALLOWED_USERS`` or ``GATEWAY_ALLOWED_USERS``) is
configured, default to ``"ignore"`` the allowlist signals that the owner has
deliberately restricted access; spamming unknown contacts with pairing codes
is both noisy and a potential info-leak. (#9337)
4. No allowlist and no explicit config ``"pair"`` (open-gateway default).
"""
config = getattr(self, "config", None)
if config and hasattr(config, "get_unauthorized_dm_behavior"):
return config.get_unauthorized_dm_behavior(platform)
# Check for an explicit per-platform override first.
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
# Operator explicitly configured behavior for this platform — respect it.
return config.get_unauthorized_dm_behavior(platform)
# Check for an explicit global config override.
if config and hasattr(config, "unauthorized_dm_behavior"):
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
return config.unauthorized_dm_behavior
# No explicit override. Fall back to allowlist-aware default:
# if any allowlist is configured for this platform, silently drop
# unauthorized messages instead of sending pairing codes.
if platform:
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
}
if os.getenv(platform_env_map.get(platform, ""), "").strip():
return "ignore"
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
return "ignore"
return "pair"
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
@@ -3012,6 +3180,10 @@ class GatewayRunner:
_quick_key[:30], _stale_age, _stale_idle,
_raw_stale_timeout, _stale_detail,
)
self._invalidate_session_run_generation(
_quick_key,
reason="stale_running_agent_eviction",
)
self._release_running_agent_state(_quick_key)
if _quick_key in self._running_agents:
@@ -3035,15 +3207,12 @@ class GatewayRunner:
# _interrupt_requested. Force-clean _running_agents so the session
# is unlocked and subsequent messages are processed normally.
if _cmd_def_inner and _cmd_def_inner.name == "stop":
running_agent = self._running_agents.get(_quick_key)
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
running_agent.interrupt("Stop requested")
# Force-clean: remove the session lock regardless of agent state
adapter = self.adapters.get(source.platform)
if adapter and hasattr(adapter, 'get_pending_message'):
adapter.get_pending_message(_quick_key) # consume and discard
self._pending_messages.pop(_quick_key, None)
self._release_running_agent_state(_quick_key)
await self._interrupt_and_clear_session(
_quick_key,
source,
interrupt_reason=_INTERRUPT_REASON_STOP,
invalidation_reason="stop_command",
)
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
return "⚡ Stopped. You can continue this session."
@@ -3055,17 +3224,15 @@ class GatewayRunner:
# doesn't get re-processed as a user message after the
# interrupt completes.
if _cmd_def_inner and _cmd_def_inner.name == "new":
running_agent = self._running_agents.get(_quick_key)
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
running_agent.interrupt("Session reset requested")
# Clear any pending messages so the old text doesn't replay
adapter = self.adapters.get(source.platform)
if adapter and hasattr(adapter, 'get_pending_message'):
adapter.get_pending_message(_quick_key) # consume and discard
self._pending_messages.pop(_quick_key, None)
await self._interrupt_and_clear_session(
_quick_key,
source,
interrupt_reason=_INTERRUPT_REASON_RESET,
invalidation_reason="new_command",
)
# Clean up the running agent entry so the reset handler
# doesn't think an agent is still active.
self._release_running_agent_state(_quick_key)
return await self._handle_reset_command(event)
# /queue <prompt> — queue without interrupting
@@ -3156,6 +3323,20 @@ class GatewayRunner:
if _cmd_def_inner and _cmd_def_inner.name == "background":
return await self._handle_background_command(event)
# Session-level toggles that are safe to run mid-agent —
# /yolo can unblock a pending approval prompt, /verbose cycles
# the tool-progress display mode for the ongoing stream.
# Both modify session state without needing agent interaction
# and must not be queued (the safety net would discard them).
# /fast and /reasoning are config-only and take effect next
# message, so they fall through to the catch-all busy response
# below — users should wait and set them between turns.
if _cmd_def_inner and _cmd_def_inner.name in ("yolo", "verbose"):
if _cmd_def_inner.name == "yolo":
return await self._handle_yolo_command(event)
if _cmd_def_inner.name == "verbose":
return await self._handle_verbose_command(event)
# Gateway-handled info/control commands with dedicated
# running-agent handlers.
if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
@@ -3546,9 +3727,10 @@ class GatewayRunner:
# same session — corrupting the transcript.
self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL
self._running_agents_ts[_quick_key] = time.time()
_run_generation = self._begin_session_run_generation(_quick_key)
try:
return await self._handle_message_with_agent(event, source, _quick_key)
return await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
finally:
# If _run_agent replaced the sentinel with a real agent and
# then cleaned it up, this is a no-op. If we exited early
@@ -3719,7 +3901,7 @@ class GatewayRunner:
return message_text
async def _handle_message_with_agent(self, event, source, _quick_key: str):
async def _handle_message_with_agent(self, event, source, _quick_key: str, run_generation: int):
"""Inner handler that runs under the _running_agents sentinel guard."""
_msg_start_time = time.time()
_platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
@@ -4176,6 +4358,15 @@ class GatewayRunner:
if message_text is None:
return
# Bind this gateway run generation to the adapter's active-session
# event so deferred post-delivery callbacks can be released by the
# same run that registered them.
self._bind_adapter_run_generation(
self.adapters.get(source.platform),
session_key,
run_generation,
)
try:
# Emit agent:start hook
hook_ctx = {
@@ -4194,6 +4385,7 @@ class GatewayRunner:
source=source,
session_id=session_entry.session_id,
session_key=session_key,
run_generation=run_generation,
event_message_id=event.message_id,
channel_prompt=event.channel_prompt,
)
@@ -4206,6 +4398,22 @@ class GatewayRunner:
except Exception:
pass
if not self._is_session_run_current(_quick_key, run_generation):
logger.info(
"Discarding stale agent result for %s — generation %d is no longer current",
_quick_key[:20] if _quick_key else "?",
run_generation,
)
_stale_adapter = self.adapters.get(source.platform)
if getattr(type(_stale_adapter), "pop_post_delivery_callback", None) is not None:
_stale_adapter.pop_post_delivery_callback(
_quick_key,
generation=run_generation,
)
elif _stale_adapter and hasattr(_stale_adapter, "_post_delivery_callbacks"):
_stale_adapter._post_delivery_callbacks.pop(_quick_key, None)
return None
response = agent_result.get("final_response") or ""
# Convert the agent's internal "(empty)" sentinel into a
@@ -4620,6 +4828,7 @@ class GatewayRunner:
# Get existing session key
session_key = self._session_key_for_source(source)
self._invalidate_session_run_generation(session_key, reason="session_reset")
# Flush memories in the background (fire-and-forget) so the user
# gets the "Session reset!" response immediately.
@@ -4879,14 +5088,23 @@ class GatewayRunner:
agent = self._running_agents.get(session_key)
if agent is _AGENT_PENDING_SENTINEL:
# Force-clean the sentinel so the session is unlocked.
self._release_running_agent_state(session_key)
await self._interrupt_and_clear_session(
session_key,
source,
interrupt_reason=_INTERRUPT_REASON_STOP,
invalidation_reason="stop_command_pending",
)
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.
self._release_running_agent_state(session_key)
await self._interrupt_and_clear_session(
session_key,
source,
interrupt_reason=_INTERRUPT_REASON_STOP,
invalidation_reason="stop_command_handler",
)
return "⚡ Stopped. You can continue this session."
else:
return "No active task to stop."
@@ -5664,11 +5882,13 @@ class GatewayRunner:
"""Handle /voice [on|off|tts|channel|leave|status] command."""
args = event.get_command_args().strip().lower()
chat_id = event.source.chat_id
platform = event.source.platform
voice_key = self._voice_key(platform, chat_id)
adapter = self.adapters.get(event.source.platform)
adapter = self.adapters.get(platform)
if args in ("on", "enable"):
self._voice_mode[chat_id] = "voice_only"
self._voice_mode[voice_key] = "voice_only"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
@@ -5678,13 +5898,13 @@ class GatewayRunner:
"Use /voice tts to get voice replies for all messages."
)
elif args in ("off", "disable"):
self._voice_mode[chat_id] = "off"
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
return "Voice mode disabled. Text-only replies."
elif args == "tts":
self._voice_mode[chat_id] = "all"
self._voice_mode[voice_key] = "all"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
@@ -5697,7 +5917,7 @@ class GatewayRunner:
elif args == "leave":
return await self._handle_voice_channel_leave(event)
elif args == "status":
mode = self._voice_mode.get(chat_id, "off")
mode = self._voice_mode.get(voice_key, "off")
labels = {
"off": "Off (text only)",
"voice_only": "On (voice reply to voice messages)",
@@ -5721,15 +5941,15 @@ class GatewayRunner:
return f"Voice mode: {labels.get(mode, mode)}"
else:
# Toggle: off → on, on/all → off
current = self._voice_mode.get(chat_id, "off")
current = self._voice_mode.get(voice_key, "off")
if current == "off":
self._voice_mode[chat_id] = "voice_only"
self._voice_mode[voice_key] = "voice_only"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
return "Voice mode enabled."
else:
self._voice_mode[chat_id] = "off"
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
@@ -5775,7 +5995,7 @@ class GatewayRunner:
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._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all"
self._save_voice_modes()
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
return (
@@ -5802,7 +6022,7 @@ class GatewayRunner:
except Exception as e:
logger.warning("Error leaving voice channel: %s", e)
# Always clean up state even if leave raised an exception
self._voice_mode[event.source.chat_id] = "off"
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "off"
self._save_voice_modes()
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
if hasattr(adapter, "_voice_input_callback"):
@@ -5814,7 +6034,7 @@ class GatewayRunner:
Cleans up runner-side voice_mode state that the adapter cannot reach.
"""
self._voice_mode[chat_id] = "off"
self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off"
self._save_voice_modes()
adapter = self.adapters.get(Platform.DISCORD)
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
@@ -5900,7 +6120,7 @@ class GatewayRunner:
return False
chat_id = event.source.chat_id
voice_mode = self._voice_mode.get(chat_id, "off")
voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off")
is_voice_input = (event.message_type == MessageType.VOICE)
should = (
@@ -8333,6 +8553,84 @@ class GatewayRunner:
if hasattr(self, "_busy_ack_ts"):
self._busy_ack_ts.pop(session_key, None)
def _begin_session_run_generation(self, session_key: str) -> int:
"""Claim a fresh run generation token for ``session_key``.
Every top-level gateway turn gets a monotonically increasing token.
If a later command like /stop or /new invalidates that token while the
old worker is still unwinding, the late result can be recognized and
dropped instead of bleeding into the fresh session.
"""
if not session_key:
return 0
generations = self.__dict__.get("_session_run_generation")
if generations is None:
generations = {}
self._session_run_generation = generations
next_generation = int(generations.get(session_key, 0)) + 1
generations[session_key] = next_generation
return next_generation
def _invalidate_session_run_generation(self, session_key: str, *, reason: str = "") -> int:
"""Invalidate any in-flight run token for ``session_key``."""
generation = self._begin_session_run_generation(session_key)
if reason:
logger.info(
"Invalidated run generation for %s%d (%s)",
session_key[:20],
generation,
reason,
)
return generation
def _is_session_run_current(self, session_key: str, generation: int) -> bool:
"""Return True when ``generation`` is still current for ``session_key``."""
if not session_key:
return True
generations = self.__dict__.get("_session_run_generation") or {}
return int(generations.get(session_key, 0)) == int(generation)
def _bind_adapter_run_generation(
self,
adapter: Any,
session_key: str,
generation: int | None,
) -> None:
"""Bind a gateway run generation to the adapter's active-session event."""
if not adapter or not session_key or generation is None:
return
try:
interrupt_event = getattr(adapter, "_active_sessions", {}).get(session_key)
if interrupt_event is not None:
setattr(interrupt_event, "_hermes_run_generation", int(generation))
except Exception:
pass
async def _interrupt_and_clear_session(
self,
session_key: str,
source: SessionSource,
*,
interrupt_reason: str,
invalidation_reason: str,
release_running_state: bool = True,
) -> None:
"""Interrupt the current run and clear queued session state consistently."""
if not session_key:
return
running_agent = self._running_agents.get(session_key)
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
running_agent.interrupt(interrupt_reason)
self._invalidate_session_run_generation(session_key, reason=invalidation_reason)
adapter = self.adapters.get(source.platform)
if adapter and hasattr(adapter, "interrupt_session_activity"):
await adapter.interrupt_session_activity(session_key, source.chat_id)
if adapter and hasattr(adapter, "get_pending_message"):
adapter.get_pending_message(session_key) # consume and discard
self._pending_messages.pop(session_key, None)
if release_running_state:
self._release_running_agent_state(session_key)
def _evict_cached_agent(self, session_key: str) -> None:
"""Remove a cached agent for a session (called on /new, /model, etc)."""
_lock = getattr(self, "_agent_cache_lock", None)
@@ -8514,6 +8812,7 @@ class GatewayRunner:
source: "SessionSource",
session_id: str,
session_key: str = None,
run_generation: Optional[int] = None,
event_message_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Forward the message to a remote Hermes API server instead of
@@ -8549,6 +8848,11 @@ class GatewayRunner:
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
def _run_still_current() -> bool:
if run_generation is None or not session_key:
return True
return self._is_session_run_current(session_key, run_generation)
# Build messages in OpenAI chat format --------------------------
#
# The remote api_server can maintain session continuity via
@@ -8678,6 +8982,21 @@ class GatewayRunner:
# Parse SSE stream
buffer = ""
async for chunk in resp.content.iter_any():
if not _run_still_current():
logger.info(
"Discarding stale proxy stream for %s — generation %d is no longer current",
session_key[:20] if session_key else "?",
run_generation or 0,
)
return {
"final_response": "",
"messages": [],
"api_calls": 0,
"tools": [],
"history_offset": len(history),
"session_id": session_id,
"response_previewed": False,
}
text = chunk.decode("utf-8", errors="replace")
buffer += text
@@ -8727,6 +9046,21 @@ class GatewayRunner:
stream_task.cancel()
_elapsed = time.time() - _start
if not _run_still_current():
logger.info(
"Discarding stale proxy result for %s — generation %d is no longer current",
session_key[:20] if session_key else "?",
run_generation or 0,
)
return {
"final_response": "",
"messages": [],
"api_calls": 0,
"tools": [],
"history_offset": len(history),
"session_id": session_id,
"response_previewed": False,
}
logger.info(
"proxy response: url=%s session=%s time=%.1fs response=%d chars",
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
@@ -8755,6 +9089,7 @@ class GatewayRunner:
source: SessionSource,
session_id: str,
session_key: str = None,
run_generation: Optional[int] = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
@@ -8780,11 +9115,17 @@ class GatewayRunner:
source=source,
session_id=session_id,
session_key=session_key,
run_generation=run_generation,
event_message_id=event_message_id,
)
from run_agent import AIAgent
import queue
def _run_still_current() -> bool:
if run_generation is None or not session_key:
return True
return self._is_session_run_current(session_key, run_generation)
user_config = _load_gateway_config()
platform_key = _platform_config_key(source.platform)
@@ -8839,7 +9180,7 @@ class GatewayRunner:
def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs):
"""Callback invoked by agent on tool lifecycle events."""
if not progress_queue:
if not progress_queue or not _run_still_current():
return
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
@@ -8944,6 +9285,14 @@ class GatewayRunner:
while True:
try:
if not _run_still_current():
while not progress_queue.empty():
try:
progress_queue.get_nowait()
except Exception:
break
return
raw = progress_queue.get_nowait()
# Handle dedup messages: update last line with repeat counter
@@ -8969,6 +9318,9 @@ class GatewayRunner:
await asyncio.sleep(_remaining)
continue
if not _run_still_current():
return
if can_edit and progress_msg_id is not None:
# Try to edit the existing progress message
full_text = "\n".join(progress_lines)
@@ -9004,7 +9356,8 @@ class GatewayRunner:
# Restore typing indicator
await asyncio.sleep(0.3)
await adapter.send_typing(source.chat_id, metadata=_progress_metadata)
if _run_still_current():
await adapter.send_typing(source.chat_id, metadata=_progress_metadata)
except queue.Empty:
await asyncio.sleep(0.3)
@@ -9048,6 +9401,8 @@ class GatewayRunner:
_hooks_ref = self.hooks
def _step_callback_sync(iteration: int, prev_tools: list) -> None:
if not _run_still_current():
return
try:
# prev_tools may be list[str] or list[dict] with "name"/"result"
# keys. Normalise to keep "tool_names" backward-compatible for
@@ -9078,7 +9433,7 @@ class GatewayRunner:
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
def _status_callback_sync(event_type: str, message: str) -> None:
if not _status_adapter:
if not _status_adapter or not _run_still_current():
return
try:
asyncio.run_coroutine_threadsafe(
@@ -9209,12 +9564,16 @@ class GatewayRunner:
metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
)
if _want_stream_deltas:
_stream_delta_cb = _stream_consumer.on_delta
def _stream_delta_cb(text: str) -> None:
if _run_still_current():
_stream_consumer.on_delta(text)
stream_consumer_holder[0] = _stream_consumer
except Exception as _sc_err:
logger.debug("Could not set up stream consumer: %s", _sc_err)
def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
if not _run_still_current():
return
if _stream_consumer is not None:
if already_streamed:
_stream_consumer.on_segment_break()
@@ -9318,7 +9677,7 @@ class GatewayRunner:
_bg_review_pending_lock = threading.Lock()
def _deliver_bg_review_message(message: str) -> None:
if not _status_adapter:
if not _status_adapter or not _run_still_current():
return
try:
asyncio.run_coroutine_threadsafe(
@@ -9342,7 +9701,7 @@ class GatewayRunner:
# Background review delivery — send "💾 Memory updated" etc. to user
def _bg_review_send(message: str) -> None:
if not _status_adapter:
if not _status_adapter or not _run_still_current():
return
if not _bg_review_release.is_set():
with _bg_review_pending_lock:
@@ -9355,9 +9714,16 @@ class GatewayRunner:
# Register the release hook on the adapter so base.py's finally
# block can fire it after delivering the main response.
if _status_adapter and session_key:
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
if _pdc is not None:
_pdc[session_key] = _release_bg_review_messages
if getattr(type(_status_adapter), "register_post_delivery_callback", None) is not None:
_status_adapter.register_post_delivery_callback(
session_key,
_release_bg_review_messages,
generation=run_generation,
)
else:
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
if _pdc is not None:
_pdc[session_key] = _release_bg_review_messages
# Store agent reference for interrupt support
agent_holder[0] = agent
@@ -9959,7 +10325,7 @@ class GatewayRunner:
# Interrupt the agent if it's still running so the thread
# pool worker is freed.
if _timed_out_agent and hasattr(_timed_out_agent, "interrupt"):
_timed_out_agent.interrupt("Execution timed out (inactivity)")
_timed_out_agent.interrupt(_INTERRUPT_REASON_TIMEOUT)
_timeout_mins = int(_agent_timeout // 60) or 1
@@ -10024,11 +10390,29 @@ class GatewayRunner:
if result and adapter and session_key:
pending_event = _dequeue_pending_event(adapter, session_key)
if result.get("interrupted") and not pending_event and result.get("interrupt_message"):
pending = result.get("interrupt_message")
interrupt_message = result.get("interrupt_message")
if _is_control_interrupt_message(interrupt_message):
logger.info(
"Ignoring control interrupt message for session %s: %s",
session_key[:20] if session_key else "?",
interrupt_message,
)
else:
pending = interrupt_message
elif pending_event:
pending = pending_event.text or _build_media_placeholder(pending_event)
logger.debug("Processing queued message after agent completion: '%s...'", pending[:40])
# Leftover /steer: if a steer arrived after the last tool batch
# (e.g. during the final API call), the agent couldn't inject it
# and returned it in result["pending_steer"]. Deliver it as the
# next user turn so it isn't silently dropped.
if result and not pending and not pending_event:
_leftover_steer = result.get("pending_steer")
if _leftover_steer:
pending = _leftover_steer
logger.debug("Delivering leftover /steer as next turn: '%s...'", pending[:40])
# Safety net: if the pending text is a slash command (e.g. "/stop",
# "/new"), discard it — commands should never be passed to the agent
# as user input. The primary fix is in base.py (commands bypass the
@@ -10129,7 +10513,17 @@ class GatewayRunner:
# first response has been delivered. Pop from the
# adapter's callback dict (prevents double-fire in
# base.py's finally block) and call it.
if adapter and hasattr(adapter, "_post_delivery_callbacks"):
if getattr(type(adapter), "pop_post_delivery_callback", None) is not None:
_bg_cb = adapter.pop_post_delivery_callback(
session_key,
generation=run_generation,
)
if callable(_bg_cb):
try:
_bg_cb()
except Exception:
pass
elif adapter and hasattr(adapter, "_post_delivery_callbacks"):
_bg_cb = adapter._post_delivery_callbacks.pop(session_key, None)
if callable(_bg_cb):
try:
@@ -10177,6 +10571,7 @@ class GatewayRunner:
source=next_source,
session_id=session_id,
session_key=session_key,
run_generation=run_generation,
_interrupt_depth=_interrupt_depth + 1,
event_message_id=next_message_id,
channel_prompt=next_channel_prompt,
+9 -3
View File
@@ -926,12 +926,18 @@ class SessionStore:
continue
# Never prune sessions with an active background process
# attached — the user may still be waiting on output.
# The callback is keyed by session_key (see process_registry.
# has_active_for_session); passing session_id here used to
# never match, so active sessions got pruned anyway.
if self._has_active_processes_fn is not None:
try:
if self._has_active_processes_fn(entry.session_id):
if self._has_active_processes_fn(entry.session_key):
continue
except Exception:
pass
except Exception as exc:
logger.debug(
"has_active_processes_fn raised during prune for %s: %s",
entry.session_key, exc,
)
if entry.updated_at < cutoff:
removed_keys.append(key)
for key in removed_keys:
+24
View File
@@ -571,6 +571,30 @@ class GatewayStreamConsumer:
if final_text.strip() and final_text != self._visible_prefix():
continuation = final_text
else:
# Defence-in-depth for #7183: the last edit may still show the
# cursor character because fallback mode was entered after an
# edit failure left it stuck. Try one final edit to strip it
# so the message doesn't freeze with a visible ▉. Best-effort
# — if this edit also fails (flood control still active),
# _try_strip_cursor has already been called on fallback entry
# and the adaptive-backoff retries will have had their shot.
if (
self._message_id
and self._last_sent_text
and self.cfg.cursor
and self._last_sent_text.endswith(self.cfg.cursor)
):
clean_text = self._last_sent_text[:-len(self.cfg.cursor)]
try:
result = await self.adapter.edit_message(
chat_id=self.chat_id,
message_id=self._message_id,
content=clean_text,
)
if result.success:
self._last_sent_text = clean_text
except Exception:
pass
self._already_sent = True
self._final_response_sent = True
return
+27 -5
View File
@@ -20,6 +20,7 @@ import logging
import os
import shutil
import shlex
import ssl
import stat
import base64
import hashlib
@@ -151,7 +152,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
id="gemini",
name="Google AI Studio",
auth_type="api_key",
inference_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
inference_base_url="https://generativelanguage.googleapis.com/v1beta",
api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url_env_var="GEMINI_BASE_URL",
),
@@ -353,6 +354,9 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
"""
if env_override:
return env_override
# No key → nothing to infer from. Return default without inspecting.
if not api_key:
return default_url
if api_key.startswith("sk-kimi-"):
return KIMI_CODE_BASE_URL
return default_url
@@ -480,6 +484,14 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->
if env_override:
return env_override
# No API key set → don't probe (would fire N×M HTTPS requests with an
# empty Bearer token, all returning 401). This path is hit during
# auxiliary-client auto-detection when the user has no Z.AI credentials
# at all — the caller discards the result immediately, so the probe is
# pure latency for every AIAgent construction.
if not api_key:
return default_url
# Check provider-state cache for a previously-detected endpoint.
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "zai") or {}
@@ -1652,7 +1664,7 @@ def _resolve_verify(
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
auth_state: Optional[Dict[str, Any]] = None,
) -> bool | str:
) -> bool | ssl.SSLContext:
tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {}
tls_state = tls_state if isinstance(tls_state, dict) else {}
@@ -1672,13 +1684,12 @@ def _resolve_verify(
if effective_ca:
ca_path = str(effective_ca)
if not os.path.isfile(ca_path):
import logging
logging.getLogger("hermes.auth").warning(
logger.warning(
"CA bundle path does not exist: %s — falling back to default certificates",
ca_path,
)
return True
return ca_path
return ssl.create_default_context(cafile=ca_path)
return True
@@ -2721,6 +2732,17 @@ def _update_config_for_provider(
# Clear stale base_url to prevent contamination when switching providers
model_cfg.pop("base_url", None)
# Clear stale api_key/api_mode left over from a previous custom provider.
# When the user switches from e.g. a MiniMax custom endpoint
# (api_mode=anthropic_messages, api_key=mxp-...) to a built-in provider
# (e.g. OpenRouter), the stale api_key/api_mode would override the new
# provider's credentials and transport choice. Built-in providers that
# need a specific api_mode (copilot, xai) set it at request-resolution
# time via `_copilot_runtime_api_mode` / `_detect_api_mode_for_url`, so
# removing the persisted value here is safe.
model_cfg.pop("api_key", None)
model_cfg.pop("api_mode", None)
# When switching to a non-OpenRouter provider, ensure model.default is
# valid for the new provider. An OpenRouter-formatted name like
# "anthropic/claude-opus-4.6" will fail on direct-API providers.
+1 -1
View File
@@ -201,7 +201,7 @@ def run_backup(args) -> None:
else:
zf.write(abs_path, arcname=str(rel_path))
total_bytes += abs_path.stat().st_size
except (PermissionError, OSError) as exc:
except (PermissionError, OSError, ValueError) as exc:
errors.append(f" {rel_path}: {exc}")
continue
-1
View File
@@ -24,7 +24,6 @@ _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.3-codex", ("gpt-5.2-codex",)),
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
]
+2 -3
View File
@@ -497,9 +497,8 @@ def _collect_gateway_skill_entries(
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
plugin_pairs: list[tuple[str, str]] = []
try:
from hermes_cli.plugins import get_plugin_manager
pm = get_plugin_manager()
plugin_cmds = getattr(pm, "_plugin_commands", {})
from hermes_cli.plugins import get_plugin_commands
plugin_cmds = get_plugin_commands()
for cmd_name in sorted(plugin_cmds):
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
if not name:
+145 -38
View File
@@ -13,6 +13,7 @@ This module provides:
"""
import copy
import logging
import os
import platform
import re
@@ -24,6 +25,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
logger = logging.getLogger(__name__)
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -403,7 +405,11 @@ DEFAULT_CONFIG = {
"container_persistent": True, # Persist filesystem across sessions
# Docker volume mounts — share host directories with the container.
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example: ["/home/user/projects:/workspace/projects", "/data:/data"]
# Example:
# ["/home/user/projects:/workspace/projects",
# "/home/user/.hermes/cache/documents:/output"]
# For gateway MEDIA delivery, write inside Docker to /output/... and emit
# the host-visible path in MEDIA:, not the container path.
"docker_volumes": [],
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
@@ -470,13 +476,6 @@ DEFAULT_CONFIG = {
},
},
"smart_model_routing": {
"enabled": False,
"max_simple_chars": 160,
"max_simple_words": 28,
"cheap_model": {},
},
# Auxiliary model config — provider:model for each side task.
# Format: provider is the provider name, model is the model slug.
# "auto" for provider = auto-detect best available provider.
@@ -490,6 +489,7 @@ DEFAULT_CONFIG = {
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
"timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout
"extra_body": {}, # OpenAI-compatible provider-specific request fields
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
},
"web_extract": {
@@ -498,6 +498,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
"extra_body": {},
},
"compression": {
"provider": "auto",
@@ -505,6 +506,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
"extra_body": {},
},
"session_search": {
"provider": "auto",
@@ -512,6 +514,8 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
"max_concurrency": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers
},
"skills_hub": {
"provider": "auto",
@@ -519,6 +523,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
"approval": {
"provider": "auto",
@@ -526,6 +531,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
"mcp": {
"provider": "auto",
@@ -533,6 +539,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
"flush_memories": {
"provider": "auto",
@@ -540,6 +547,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
"title_generation": {
"provider": "auto",
@@ -547,6 +555,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
},
@@ -558,9 +567,14 @@ DEFAULT_CONFIG = {
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,
"final_response_markdown": "strip", # render | strip | raw
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
"first_lines": 2,
"last_lines": 2,
},
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
@@ -708,6 +722,14 @@ DEFAULT_CONFIG = {
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# discord_server tool: restrict which actions the agent may call.
# Default (empty) = all actions allowed (subject to bot privileged intents).
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
# or YAML list. Unknown names are dropped with a warning at load time.
# Actions: list_guilds, server_info, list_channels, channel_info,
# list_roles, member_info, search_members, fetch_messages, list_pins,
# pin_message, unpin_message, create_thread, add_role, remove_role.
"server_actions": "",
},
# WhatsApp platform settings (gateway mode)
@@ -807,7 +829,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 19,
"_config_version": 21,
}
# =============================================================================
@@ -1830,12 +1852,53 @@ def _normalize_custom_provider_entry(
if not isinstance(entry, dict):
return None
# Accept camelCase aliases commonly used in hand-written configs.
_CAMEL_ALIASES: Dict[str, str] = {
"apiKey": "api_key",
"baseUrl": "base_url",
"apiMode": "api_mode",
"keyEnv": "key_env",
"defaultModel": "default_model",
"contextLength": "context_length",
"rateLimitDelay": "rate_limit_delay",
}
_KNOWN_KEYS = {
"name", "api", "url", "base_url", "api_key", "key_env",
"api_mode", "transport", "model", "default_model", "models",
"context_length", "rate_limit_delay",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
logger.warning(
"providers.%s: camelCase key '%s' auto-mapped to '%s' "
"(use snake_case to avoid this warning)",
provider_key or "?", camel, snake,
)
entry[snake] = entry[camel]
unknown = set(entry.keys()) - _KNOWN_KEYS - set(_CAMEL_ALIASES.keys())
if unknown:
logger.warning(
"providers.%s: unknown config keys ignored: %s",
provider_key or "?", ", ".join(sorted(unknown)),
)
from urllib.parse import urlparse
base_url = ""
for url_key in ("api", "url", "base_url"):
for url_key in ("base_url", "url", "api"):
raw_url = entry.get(url_key)
if isinstance(raw_url, str) and raw_url.strip():
base_url = raw_url.strip()
break
candidate = raw_url.strip()
parsed = urlparse(candidate)
if parsed.scheme and parsed.netloc:
base_url = candidate
break
else:
logger.warning(
"providers.%s: '%s' value '%s' is not a valid URL "
"(no scheme or host) — skipped",
provider_key or "?", url_key, candidate,
)
if not base_url:
return None
@@ -2464,6 +2527,72 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
else:
print(" ✓ Removed unused compression.summary_* keys")
# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
# The loader now requires plugins to appear in ``plugins.enabled`` before
# loading. Existing installs had all discovered plugins loading by default
# (minus anything in ``plugins.disabled``). To avoid silently breaking
# those setups on upgrade, populate ``plugins.enabled`` with the set of
# currently-installed user plugins that aren't already disabled.
#
# Bundled plugins (shipped in the repo itself) are NOT grandfathered —
# they ship off for everyone, including existing users, so any user who
# wants one has to opt in explicitly.
if current_ver < 21:
config = read_raw_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
plugins_cfg = {}
# Only migrate if the enabled allow-list hasn't been set yet.
if "enabled" not in plugins_cfg:
disabled = plugins_cfg.get("disabled", []) or []
if not isinstance(disabled, list):
disabled = []
disabled_set = set(disabled)
# Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
grandfathered: List[str] = []
try:
from hermes_constants import get_hermes_home as _ghome
user_plugins_dir = _ghome() / "plugins"
if user_plugins_dir.is_dir():
for child in sorted(user_plugins_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if not manifest_file.exists():
continue
try:
with open(manifest_file) as _mf:
manifest = yaml.safe_load(_mf) or {}
except Exception:
manifest = {}
name = manifest.get("name") or child.name
if name in disabled_set:
continue
grandfathered.append(name)
except Exception:
grandfathered = []
plugins_cfg["enabled"] = grandfathered
config["plugins"] = plugins_cfg
save_config(config)
results["config_added"].append(
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
)
if not quiet:
if grandfathered:
print(
f" ✓ Plugins now opt-in: grandfathered "
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
)
else:
print(
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
"Use `hermes plugins enable <name>` to activate."
)
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -2866,19 +2995,6 @@ _FALLBACK_COMMENT = """
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
#
# ── Smart Model Routing ────────────────────────────────────────────────
# Optional cheap-vs-strong routing for simple turns.
# Keeps the primary model for complex work, but can route short/simple
# messages to a cheaper model across providers.
#
# smart_model_routing:
# enabled: true
# max_simple_chars: 160
# max_simple_words: 28
# cheap_model:
# provider: openrouter
# model: google/gemini-2.5-flash
"""
@@ -2910,19 +3026,6 @@ _COMMENTED_SECTIONS = """
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
#
# ── Smart Model Routing ────────────────────────────────────────────────
# Optional cheap-vs-strong routing for simple turns.
# Keeps the primary model for complex work, but can route short/simple
# messages to a cheaper model across providers.
#
# smart_model_routing:
# enabled: true
# max_simple_chars: 160
# max_simple_words: 28
# cheap_model:
# provider: openrouter
# model: google/gemini-2.5-flash
"""
@@ -3385,6 +3488,10 @@ def show_config():
print(f" Personality: {display.get('personality', 'kawaii')}")
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
ump_first = ump.get('first_lines', 2)
ump_last = ump.get('last_lines', 2)
print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)")
# Terminal
print()
+90
View File
@@ -277,6 +277,86 @@ def run_doctor(args):
config_path = HERMES_HOME / 'config.yaml'
if config_path.exists():
check_ok(f"{_DHH}/config.yaml exists")
# Validate model.provider and model.default values
try:
import yaml as _yaml
cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
model_section = cfg.get("model") or {}
provider_raw = (model_section.get("provider") or "").strip()
provider = provider_raw.lower()
default_model = (model_section.get("default") or model_section.get("model") or "").strip()
known_providers: set = set()
try:
from hermes_cli.auth import PROVIDER_REGISTRY
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
except Exception:
pass
try:
from hermes_cli.auth import resolve_provider as _resolve_provider
except Exception:
_resolve_provider = None
canonical_provider = provider
if provider and _resolve_provider is not None and provider != "auto":
try:
canonical_provider = _resolve_provider(provider)
except Exception:
canonical_provider = None
if provider and provider != "auto":
if canonical_provider is None or (known_providers and canonical_provider not in known_providers):
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
check_fail(
f"model.provider '{provider_raw}' is not a recognised provider",
f"(known: {known_list})",
)
issues.append(
f"model.provider '{provider_raw}' is unknown. "
f"Valid providers: {known_list}. "
f"Fix: run 'hermes config set model.provider <valid_provider>'"
)
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous"):
check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)",
)
issues.append(
f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. "
"Either set model.provider to 'openrouter', or drop the vendor prefix."
)
# Check credentials for the configured provider.
# Limit to API-key providers in PROVIDER_REGISTRY — other provider
# types (OAuth, SDK, openrouter/anthropic/custom/auto) have their
# own env-var checks elsewhere in doctor, and get_auth_status()
# returns a bare {logged_in: False} for anything it doesn't
# explicitly dispatch, which would produce false positives.
if canonical_provider and canonical_provider not in ("auto", "custom", "openrouter"):
try:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(canonical_provider)
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(canonical_provider) or {}
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
if not configured:
check_fail(
f"model.provider '{canonical_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
)
issues.append(
f"No credentials found for provider '{canonical_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'"
)
except Exception:
pass
except Exception as e:
check_warn("Could not validate model/provider config", f"({e})")
else:
fallback_config = PROJECT_ROOT / 'cli-config.yaml'
if fallback_config.exists():
@@ -778,6 +858,16 @@ def run_doctor(args):
elif response.status_code == 401:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} ")
issues.append("Check OPENROUTER_API_KEY in .env")
elif response.status_code == 402:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}")
issues.append(
"OpenRouter account has insufficient credits. "
"Fix: run 'hermes config set model.provider <provider>' to switch providers, "
"or fund your OpenRouter account at https://openrouter.ai/settings/credits"
)
elif response.status_code == 429:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} ")
issues.append("OpenRouter rate limit hit — consider switching to a different provider or waiting")
else:
print(f"\r {color('', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} ")
except Exception as e:
-1
View File
@@ -160,7 +160,6 @@ def _config_overrides(config: dict) -> dict[str, str]:
("display", "streaming"),
("display", "skin"),
("display", "show_reasoning"),
("smart_model_routing", "enabled"),
("privacy", "redact_pii"),
("tts", "provider"),
]
+37 -6
View File
@@ -693,6 +693,10 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
- If it looks like a session ID (contains underscore + hex), try direct lookup first.
- Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
- Falls back to the other method if the first doesn't match.
- If the resolved session is a compression root, follow the chain forward
to the latest continuation. Users who remember the old root ID (e.g.
from an exit summary printed before the bug fix, or from notes) get
resumed at the live tip instead of a stale parent with no messages.
"""
try:
from hermes_state import SessionDB
@@ -701,14 +705,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
# Try as exact session ID first
session = db.get_session(name_or_id)
resolved_id: Optional[str] = None
if session:
db.close()
return session["id"]
resolved_id = session["id"]
else:
# Try as title (with auto-latest for lineage)
resolved_id = db.resolve_session_by_title(name_or_id)
if resolved_id:
# Project forward through compression chain so resumes land on
# the live tip instead of a dead compressed parent.
try:
resolved_id = db.get_compression_tip(resolved_id) or resolved_id
except Exception:
pass
# Try as title (with auto-latest for lineage)
session_id = db.resolve_session_by_title(name_or_id)
db.close()
return session_id
return resolved_id
except Exception:
pass
return None
@@ -2351,7 +2364,7 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
return
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-2.5-flash")
default = current_model or (models[0] if models else "gemini-3-flash-preview")
selected = _prompt_model_selection(models, current_model=default)
if selected:
_save_model_choice(selected)
@@ -7002,6 +7015,13 @@ For more help on a command:
wh_sub.add_argument(
"--secret", default="", help="HMAC secret (auto-generated if omitted)"
)
wh_sub.add_argument(
"--deliver-only",
action="store_true",
help="Skip the agent — deliver the rendered prompt directly as the "
"message. Zero LLM cost. Requires --deliver to be a real target "
"(not 'log').",
)
webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"
@@ -7429,6 +7449,17 @@ Examples:
action="store_true",
help="Remove existing plugin and reinstall",
)
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable",
action="store_true",
help="Auto-enable the plugin after install (skip confirmation prompt)",
)
_install_enable_group.add_argument(
"--no-enable",
action="store_true",
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
)
plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin"
+67 -4
View File
@@ -1035,21 +1035,49 @@ def list_authenticated_providers(
seen_slugs.add(_cp.slug.lower())
# --- 3. User-defined endpoints from config ---
# Track (name, base_url) of what section 3 emits so section 4 can skip
# any overlapping ``custom_providers:`` entries. Callers typically pass
# both (gateway/CLI invoke ``get_compatible_custom_providers()`` which
# merges ``providers:`` into the list) — without this, the same endpoint
# produces two picker rows: one bare-slug ("openrouter") from section 3
# and one "custom:openrouter" from section 4, both labelled identically.
_section3_emitted_pairs: set = set()
if user_providers and isinstance(user_providers, dict):
for ep_name, ep_cfg in user_providers.items():
if not isinstance(ep_cfg, dict):
continue
# Skip if this slug was already emitted (e.g. canonical provider
# with the same name) or will be picked up by section 4.
if ep_name.lower() in seen_slugs:
continue
display_name = ep_cfg.get("name", "") or ep_name
api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
default_model = ep_cfg.get("default_model", "")
# ``base_url`` is Hermes's canonical write key (matches
# custom_providers and _save_custom_provider); ``api`` / ``url``
# remain as fallbacks for hand-edited / legacy configs.
api_url = (
ep_cfg.get("base_url", "")
or ep_cfg.get("api", "")
or ep_cfg.get("url", "")
or ""
)
# ``default_model`` is the legacy key; ``model`` matches what
# custom_providers entries use, so accept either.
default_model = ep_cfg.get("default_model", "") or ep_cfg.get("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
# Also include the full models list from config.
# Hermes writes ``models:`` as a dict keyed by model id
# (see hermes_cli/main.py::_save_custom_provider); older
# configs or hand-edited files may still use a list.
cfg_models = ep_cfg.get("models", [])
if isinstance(cfg_models, list):
if isinstance(cfg_models, dict):
for m in cfg_models:
if m and m not in models_list:
models_list.append(m)
elif isinstance(cfg_models, list):
for m in cfg_models:
if m and m not in models_list:
models_list.append(m)
@@ -1066,6 +1094,13 @@ def list_authenticated_providers(
"source": "user-config",
"api_url": api_url,
})
seen_slugs.add(ep_name.lower())
_pair = (
str(display_name).strip().lower(),
str(api_url).strip().rstrip("/").lower(),
)
if _pair[0] and _pair[1]:
_section3_emitted_pairs.add(_pair)
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
@@ -1100,13 +1135,41 @@ def list_authenticated_providers(
"api_url": api_url,
"models": [],
}
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
# stores every configured model as a dict under ``models:``;
# downstream readers (agent/models_dev.py, gateway/run.py,
# run_agent.py, hermes_cli/config.py) already consume that dict.
# The /model picker previously ignored it, so multi-model
# custom providers appeared to have only the active model.
default_model = (entry.get("model") or "").strip()
if default_model and default_model not in groups[slug]["models"]:
groups[slug]["models"].append(default_model)
cfg_models = entry.get("models", {})
if isinstance(cfg_models, dict):
for m in cfg_models:
if m and m not in groups[slug]["models"]:
groups[slug]["models"].append(m)
elif isinstance(cfg_models, list):
for m in cfg_models:
if m and m not in groups[slug]["models"]:
groups[slug]["models"].append(m)
for slug, grp in groups.items():
if slug.lower() in seen_slugs:
continue
# Skip if section 3 already emitted this endpoint under its
# ``providers:`` dict key — matches on (display_name, base_url),
# the tuple section 4 groups by. Prevents two picker rows
# labelled identically when callers pass both ``user_providers``
# and a compatibility-merged ``custom_providers`` list.
_pair_key = (
str(grp["name"]).strip().lower(),
str(grp["api_url"]).strip().rstrip("/").lower(),
)
if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs:
continue
results.append({
"slug": slug,
"name": grp["name"],
+57 -9
View File
@@ -16,6 +16,12 @@ from difflib import get_close_matches
from pathlib import Path
from typing import Any, NamedTuple, Optional
from hermes_cli import __version__ as _HERMES_VERSION
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
COPILOT_EDITOR_VERSION = "vscode/1.104.1"
@@ -128,16 +134,14 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
],
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
],
"google-gemini-cli": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
],
"zai": [
"glm-5.1",
@@ -227,7 +231,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
@@ -552,7 +555,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
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("gemini", "Google AI Studio", "Google AI Studio (Gemini models — native Gemini API)"),
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (free tier supported; no API key needed)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
@@ -1771,7 +1774,7 @@ def probe_api_models(
candidates.append((alternate_base, True))
tried: list[str] = []
headers: dict[str, str] = {}
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if normalized.startswith(COPILOT_BASE_URL):
@@ -2106,6 +2109,51 @@ def validate_requested_model(
),
}
# MiniMax providers don't expose a /models endpoint — validate against
# the static catalog instead, similar to openai-codex.
if normalized in ("minimax", "minimax-cn"):
try:
catalog_models = provider_model_ids(normalized)
except Exception:
catalog_models = []
if catalog_models:
# Case-insensitive lookup (catalog uses mixed case like MiniMax-M2.7)
catalog_lower = {m.lower(): m for m in catalog_models}
if requested_for_lookup.lower() in catalog_lower:
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# Auto-correct close matches (case-insensitive)
catalog_lower_list = list(catalog_lower.keys())
auto = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9)
if auto:
corrected = catalog_lower[auto[0]]
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": corrected,
"message": f"Auto-corrected `{requested}` → `{corrected}`",
}
suggestions = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{catalog_lower[s]}`" for s in suggestions)
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the MiniMax catalog."
f"{suggestion_text}"
"\n MiniMax does not expose a /models endpoint, so Hermes cannot verify the model name."
"\n The model may still work if it exists on the server."
),
}
# Probe the live API to check if the model actually exists
api_models = fetch_api_models(api_key, base_url)
+118 -16
View File
@@ -2,14 +2,20 @@
Hermes Plugin System
====================
Discovers, loads, and manages plugins from three sources:
Discovers, loads, and manages plugins from four sources:
1. **User plugins** ``~/.hermes/plugins/<name>/``
2. **Project plugins** ``./.hermes/plugins/<name>/`` (opt-in via
1. **Bundled plugins** ``<repo>/plugins/<name>/`` (shipped with hermes-agent;
``memory/`` and ``context_engine/`` subdirs are excluded they have their
own discovery paths)
2. **User plugins** ``~/.hermes/plugins/<name>/``
3. **Project plugins** ``./.hermes/plugins/<name>/`` (opt-in via
``HERMES_ENABLE_PROJECT_PLUGINS``)
3. **Pip plugins** packages that expose the ``hermes_agent.plugins``
4. **Pip plugins** packages that expose the ``hermes_agent.plugins``
entry-point group.
Later sources override earlier ones on name collision, so a user or project
plugin with the same name as a bundled plugin replaces it.
Each directory plugin must contain a ``plugin.yaml`` manifest **and** an
``__init__.py`` with a ``register(ctx)`` function.
@@ -54,6 +60,8 @@ logger = logging.getLogger(__name__)
VALID_HOOKS: Set[str] = {
"pre_tool_call",
"post_tool_call",
"transform_terminal_output",
"transform_tool_result",
"pre_llm_call",
"post_llm_call",
"pre_api_request",
@@ -75,7 +83,12 @@ def _env_enabled(name: str) -> bool:
def _get_disabled_plugins() -> set:
"""Read the disabled plugins list from config.yaml."""
"""Read the disabled plugins list from config.yaml.
Kept for backward compat and explicit deny-list semantics. A plugin
name in this set will never load, even if it appears in
``plugins.enabled``.
"""
try:
from hermes_cli.config import load_config
config = load_config()
@@ -85,6 +98,36 @@ def _get_disabled_plugins() -> set:
return set()
def _get_enabled_plugins() -> Optional[set]:
"""Read the enabled-plugins allow-list from config.yaml.
Plugins are opt-in by default only plugins whose name appears in
this set are loaded. Returns:
* ``None`` the key is missing or malformed. Callers should treat
this as "nothing enabled yet" (the opt-in default); the first
``migrate_config`` run populates the key with a grandfathered set
of currently-installed user plugins so existing setups don't
break on upgrade.
* ``set()`` an empty list was explicitly set; nothing loads.
* ``set(...)`` the concrete allow-list.
"""
try:
from hermes_cli.config import load_config
config = load_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
return None
if "enabled" not in plugins_cfg:
return None
enabled = plugins_cfg.get("enabled")
if not isinstance(enabled, list):
return None
return set(enabled)
except Exception:
return None
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@@ -420,27 +463,66 @@ class PluginManager:
manifests: List[PluginManifest] = []
# 1. User plugins (~/.hermes/plugins/)
# 1. Bundled plugins (<repo>/plugins/<name>/)
# Repo-shipped generic plugins live next to hermes_cli/. Memory and
# context_engine subdirs are handled by their own discovery paths, so
# skip those names here. Bundled plugins are discovered (so they
# show up in `hermes plugins`) but only loaded when added to
# `plugins.enabled` in config.yaml — opt-in like any other plugin.
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
manifests.extend(
self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine"},
)
)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
manifests.extend(self._scan_directory(user_dir, source="user"))
# 2. Project plugins (./.hermes/plugins/)
# 3. Project plugins (./.hermes/plugins/)
if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
project_dir = Path.cwd() / ".hermes" / "plugins"
manifests.extend(self._scan_directory(project_dir, source="project"))
# 3. Pip / entry-point plugins
# 4. Pip / entry-point plugins
manifests.extend(self._scan_entry_points())
# Load each manifest (skip user-disabled plugins)
# Load each manifest (skip user-disabled plugins).
# Later sources override earlier ones on name collision — user plugins
# take precedence over bundled, project plugins take precedence over
# user. Dedup here so we only load the final winner.
disabled = _get_disabled_plugins()
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
winners[manifest.name] = manifest
for manifest in winners.values():
# Explicit disable always wins.
if manifest.name in disabled:
loaded = LoadedPlugin(manifest=manifest, enabled=False)
loaded.error = "disabled via config"
self._plugins[manifest.name] = loaded
logger.debug("Skipping disabled plugin '%s'", manifest.name)
continue
# Opt-in gate: plugins must be in the enabled allow-list.
# If the allow-list is missing (None), treat as "nothing enabled"
# — users have to explicitly enable plugins to load them.
# Memory and context_engine providers are excluded from this gate
# since they have their own single-select config (memory.provider
# / context.engine), not the enabled list.
if enabled is None or manifest.name not in enabled:
loaded = LoadedPlugin(manifest=manifest, enabled=False)
loaded.error = "not enabled in config (run `hermes plugins enable {}` to activate)".format(
manifest.name
)
self._plugins[manifest.name] = loaded
logger.debug(
"Skipping '%s' (not in plugins.enabled)", manifest.name
)
continue
self._load_plugin(manifest)
if manifests:
@@ -454,8 +536,18 @@ class PluginManager:
# Directory scanning
# -----------------------------------------------------------------------
def _scan_directory(self, path: Path, source: str) -> List[PluginManifest]:
"""Read ``plugin.yaml`` manifests from subdirectories of *path*."""
def _scan_directory(
self,
path: Path,
source: str,
skip_names: Optional[Set[str]] = None,
) -> List[PluginManifest]:
"""Read ``plugin.yaml`` manifests from subdirectories of *path*.
*skip_names* is an optional allow-list of names to ignore (used
for the bundled scan to exclude ``memory`` / ``context_engine``
subdirs that have their own discovery path).
"""
manifests: List[PluginManifest] = []
if not path.is_dir():
return manifests
@@ -463,6 +555,8 @@ class PluginManager:
for child in sorted(path.iterdir()):
if not child.is_dir():
continue
if skip_names and child.name in skip_names:
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
@@ -530,7 +624,7 @@ class PluginManager:
loaded = LoadedPlugin(manifest=manifest)
try:
if manifest.source in ("user", "project"):
if manifest.source in ("user", "project", "bundled"):
module = self._load_directory_module(manifest)
else:
module = self._load_entrypoint_module(manifest)
@@ -779,23 +873,31 @@ def get_pre_tool_call_block_message(
return None
def _ensure_plugins_discovered() -> PluginManager:
"""Return the global manager after running idempotent plugin discovery."""
manager = get_plugin_manager()
manager.discover_and_load()
return manager
def get_plugin_context_engine():
"""Return the plugin-registered context engine, or None."""
return get_plugin_manager()._context_engine
return _ensure_plugins_discovered()._context_engine
def get_plugin_command_handler(name: str) -> Optional[Callable]:
"""Return the handler for a plugin-registered slash command, or ``None``."""
entry = get_plugin_manager()._plugin_commands.get(name)
entry = _ensure_plugins_discovered()._plugin_commands.get(name)
return entry["handler"] if entry else None
def get_plugin_commands() -> Dict[str, dict]:
"""Return the full plugin commands dict (name → {handler, description, plugin}).
Safe to call before discovery returns an empty dict if no plugins loaded.
Triggers idempotent plugin discovery so callers can use plugin commands
before any explicit discover_plugins() call.
"""
return get_plugin_manager()._plugin_commands
return _ensure_plugins_discovered()._plugin_commands
def get_plugin_toolsets() -> List[tuple]:
+244 -92
View File
@@ -15,6 +15,7 @@ import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home
@@ -281,8 +282,16 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
# ---------------------------------------------------------------------------
def cmd_install(identifier: str, force: bool = False) -> None:
"""Install a plugin from a Git URL or owner/repo shorthand."""
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
) -> None:
"""Install a plugin from a Git URL or owner/repo shorthand.
After install, prompt "Enable now? [y/N]" unless *enable* is provided
(True = auto-enable without prompting, False = install disabled).
"""
import tempfile
from rich.console import Console
@@ -391,6 +400,40 @@ def cmd_install(identifier: str, force: bool = False) -> None:
_display_after_install(target, identifier)
# Determine the canonical plugin name for enable-list bookkeeping.
installed_name = installed_manifest.get("name") or target.name
# Decide whether to enable: explicit flag > interactive prompt > default off
should_enable = enable
if should_enable is None:
# Interactive prompt unless stdin isn't a TTY (scripted install).
if sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(
f" Enable '{installed_name}' now? [y/N]: "
).strip().lower()
should_enable = answer in ("y", "yes")
except (EOFError, KeyboardInterrupt):
should_enable = False
else:
should_enable = False
if should_enable:
enabled = _get_enabled_set()
disabled = _get_disabled_set()
enabled.add(installed_name)
disabled.discard(installed_name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled."
)
else:
console.print(
f"[dim]Plugin installed but not enabled. "
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]"
)
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
console.print("[dim] hermes gateway restart[/dim]")
console.print()
@@ -468,7 +511,11 @@ def cmd_remove(name: str) -> None:
def _get_disabled_set() -> set:
"""Read the disabled plugins set from config.yaml."""
"""Read the disabled plugins set from config.yaml.
An explicit deny-list. A plugin name here never loads, even if also
listed in ``plugins.enabled``.
"""
try:
from hermes_cli.config import load_config
config = load_config()
@@ -488,103 +535,196 @@ def _save_disabled_set(disabled: set) -> None:
save_config(config)
def _get_enabled_set() -> set:
"""Read the enabled plugins allow-list from config.yaml.
Plugins are opt-in: only names here are loaded. Returns ``set()`` if
the key is missing (same behaviour as "nothing enabled yet").
"""
try:
from hermes_cli.config import load_config
config = load_config()
plugins_cfg = config.get("plugins", {})
if not isinstance(plugins_cfg, dict):
return set()
enabled = plugins_cfg.get("enabled", [])
return set(enabled) if isinstance(enabled, list) else set()
except Exception:
return set()
def _save_enabled_set(enabled: set) -> None:
"""Write the enabled plugins list to config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
if "plugins" not in config:
config["plugins"] = {}
config["plugins"]["enabled"] = sorted(enabled)
save_config(config)
def cmd_enable(name: str) -> None:
"""Enable a previously disabled plugin."""
"""Add a plugin to the enabled allow-list (and remove it from disabled)."""
from rich.console import Console
console = Console()
plugins_dir = _plugins_dir()
# Verify the plugin exists
target = plugins_dir / name
if not target.is_dir():
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
# Discover the plugin — check installed (user) AND bundled.
if not _plugin_exists(name):
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if name not in disabled:
if name in enabled and name not in disabled:
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
return
enabled.add(name)
disabled.discard(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. Takes effect on next session.")
console.print(
f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
"Takes effect on next session."
)
def cmd_disable(name: str) -> None:
"""Disable a plugin without removing it."""
"""Remove a plugin from the enabled allow-list (and add to disabled)."""
from rich.console import Console
console = Console()
plugins_dir = _plugins_dir()
# Verify the plugin exists
target = plugins_dir / name
if not target.is_dir():
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
if not _plugin_exists(name):
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if name in disabled:
if name not in enabled and name in disabled:
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
return
enabled.discard(name)
disabled.add(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
console.print(
f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
"Takes effect on next session."
)
def cmd_list() -> None:
"""List installed plugins."""
from rich.console import Console
from rich.table import Table
def _plugin_exists(name: str) -> bool:
"""Return True if a plugin with *name* is installed (user) or bundled."""
# Installed: directory name or manifest name match in user plugins dir
user_dir = _plugins_dir()
if user_dir.is_dir():
if (user_dir / name).is_dir():
return True
for child in user_dir.iterdir():
if not child.is_dir():
continue
manifest = _read_manifest(child)
if manifest.get("name") == name:
return True
# Bundled: <repo>/plugins/<name>/
from pathlib import Path as _P
import hermes_cli
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
if repo_plugins.is_dir():
candidate = repo_plugins / name
if candidate.is_dir() and (
(candidate / "plugin.yaml").exists()
or (candidate / "plugin.yml").exists()
):
return True
return False
def _discover_all_plugins() -> list:
"""Return a list of (name, version, description, source, dir_path) for
every plugin the loader can see user + bundled + project.
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
bundled first, then user, then project; user overrides bundled on
name collision.
"""
try:
import yaml
except ImportError:
yaml = None
console = Console()
plugins_dir = _plugins_dir()
seen: dict = {} # name -> (name, version, description, source, path)
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
if not dirs:
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
import hermes_cli
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
if not base.is_dir():
continue
for d in sorted(base.iterdir()):
if not d.is_dir():
continue
if source == "bundled" and d.name in ("memory", "context_engine"):
continue
manifest_file = d / "plugin.yaml"
if not manifest_file.exists():
manifest_file = d / "plugin.yml"
if not manifest_file.exists():
continue
name = d.name
version = ""
description = ""
if yaml:
try:
with open(manifest_file) as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
# User plugins override bundled on name collision.
if name in seen and source == "bundled":
continue
src_label = source
if source == "user" and (d / ".git").exists():
src_label = "git"
seen[name] = (name, version, description, src_label, d)
return list(seen.values())
def cmd_list() -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.console import Console
from rich.table import Table
console = Console()
entries = _discover_all_plugins()
if not entries:
console.print("[dim]No plugins installed.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
return
enabled = _get_enabled_set()
disabled = _get_disabled_set()
table = Table(title="Installed Plugins", show_lines=False)
table = Table(title="Plugins", show_lines=False)
table.add_column("Name", style="bold")
table.add_column("Status")
table.add_column("Version", style="dim")
table.add_column("Description")
table.add_column("Source", style="dim")
for d in dirs:
manifest_file = d / "plugin.yaml"
name = d.name
version = ""
description = ""
source = "local"
if manifest_file.exists() and yaml:
try:
with open(manifest_file) as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
# Check if it's a git repo (installed via hermes plugins install)
if (d / ".git").exists():
source = "git"
is_disabled = name in disabled or d.name in disabled
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
for name, version, description, source, _dir in entries:
if name in disabled:
status = "[red]disabled[/red]"
elif name in enabled:
status = "[green]enabled[/green]"
else:
status = "[yellow]not enabled[/yellow]"
table.add_row(name, status, str(version), description, source)
console.print()
@@ -592,6 +732,7 @@ def cmd_list() -> None:
console.print()
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
# ---------------------------------------------------------------------------
@@ -742,41 +883,25 @@ def cmd_toggle() -> None:
"""Interactive composite UI — general plugins + provider plugin categories."""
from rich.console import Console
try:
import yaml
except ImportError:
yaml = None
console = Console()
plugins_dir = _plugins_dir()
# -- General plugins discovery --
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
disabled = _get_disabled_set()
# -- General plugins discovery (bundled + user) --
entries = _discover_all_plugins()
enabled_set = _get_enabled_set()
disabled_set = _get_disabled_set()
plugin_names = []
plugin_labels = []
plugin_selected = set()
for i, d in enumerate(dirs):
manifest_file = d / "plugin.yaml"
name = d.name
description = ""
if manifest_file.exists() and yaml:
try:
with open(manifest_file) as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
description = manifest.get("description", "")
except Exception:
pass
plugin_names.append(name)
for i, (name, _version, description, source, _d) in enumerate(entries):
label = f"{name} \u2014 {description}" if description else name
if source == "bundled":
label = f"{label} [bundled]"
plugin_names.append(name)
plugin_labels.append(label)
if name not in disabled and d.name not in disabled:
# Selected (enabled) when in enabled-set AND not in disabled-set
if name in enabled_set and name not in disabled_set:
plugin_selected.add(i)
# -- Provider categories --
@@ -804,10 +929,10 @@ def cmd_toggle() -> None:
try:
import curses
_run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
disabled, categories, console)
disabled_set, categories, console)
except ImportError:
_run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
disabled, categories, console)
disabled_set, categories, console)
def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
@@ -1020,18 +1145,29 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
curses.wrapper(_draw)
flush_stdin()
# Persist general plugin changes
new_disabled = set()
# Persist general plugin changes. The new allow-list is the set of
# plugin names that were checked; anything not checked is explicitly
# disabled (written to disabled-list) so it remains off even if the
# plugin code does something clever like auto-enable in the future.
new_enabled: set = set()
new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins
for i, name in enumerate(plugin_names):
if i not in chosen:
if i in chosen:
new_enabled.add(name)
new_disabled.discard(name)
else:
new_disabled.add(name)
if new_disabled != disabled:
prev_enabled = _get_enabled_set()
enabled_changed = new_enabled != prev_enabled
disabled_changed = new_disabled != disabled
if enabled_changed or disabled_changed:
_save_enabled_set(new_enabled)
_save_disabled_set(new_disabled)
enabled_count = len(plugin_names) - len(new_disabled)
console.print(
f"\n[green]\u2713[/green] General plugins: {enabled_count} enabled, "
f"{len(new_disabled)} disabled."
f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, "
f"{len(plugin_names) - len(new_enabled)} disabled."
)
elif n_plugins > 0:
console.print("\n[dim]General plugins unchanged.[/dim]")
@@ -1078,11 +1214,17 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
return
print()
new_disabled = set()
new_enabled: set = set()
new_disabled: set = set(disabled)
for i, name in enumerate(plugin_names):
if i not in chosen:
if i in chosen:
new_enabled.add(name)
new_disabled.discard(name)
else:
new_disabled.add(name)
if new_disabled != disabled:
prev_enabled = _get_enabled_set()
if new_enabled != prev_enabled or new_disabled != disabled:
_save_enabled_set(new_enabled)
_save_disabled_set(new_disabled)
# Provider categories
@@ -1108,7 +1250,17 @@ def plugins_command(args) -> None:
action = getattr(args, "plugins_action", None)
if action == "install":
cmd_install(args.identifier, force=getattr(args, "force", False))
# Map argparse tri-state: --enable=True, --no-enable=False, neither=None (prompt)
enable_arg = None
if getattr(args, "enable", False):
enable_arg = True
elif getattr(args, "no_enable", False):
enable_arg = False
cmd_install(
args.identifier,
force=getattr(args, "force", False),
enable=enable_arg,
)
elif action == "update":
cmd_update(args.name)
elif action in ("remove", "rm", "uninstall"):
+6 -2
View File
@@ -322,12 +322,16 @@ def normalize_provider(name: str) -> str:
def get_provider(name: str) -> Optional[ProviderDef]:
"""Look up a provider by id or alias, merging all data sources.
"""Look up a built-in provider by id or alias.
Resolution order:
1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.)
2. models.dev catalog + Hermes overlay
3. User-defined providers from config (TODO: Phase 4)
User-defined providers from config.yaml (``providers:`` / ``custom_providers:``)
are resolved by :func:`resolve_provider_full`, which layers ``resolve_user_provider``
and ``resolve_custom_provider`` on top of this function. Callers that need
user-config support should use ``resolve_provider_full`` instead.
Returns a fully-resolved ProviderDef or None.
"""
+27 -10
View File
@@ -38,14 +38,21 @@ def _normalize_custom_provider_name(value: str) -> str:
def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
"""Auto-detect api_mode from the resolved base URL.
Direct api.openai.com endpoints need the Responses API for GPT-5.x
tool calls with reasoning (chat/completions returns 400).
- Direct api.openai.com endpoints need the Responses API for GPT-5.x
tool calls with reasoning (chat/completions returns 400).
- Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM,
LiteLLM proxies, etc.) conventionally expose the native Anthropic
protocol under a ``/anthropic`` suffix treat those as
``anthropic_messages`` transport instead of the default
``chat_completions``.
"""
normalized = (base_url or "").strip().lower().rstrip("/")
if "api.x.ai" in normalized:
return "codex_responses"
if "api.openai.com" in normalized and "openrouter" not in normalized:
return "codex_responses"
if normalized.endswith("/anthropic"):
return "anthropic_messages"
return None
@@ -194,8 +201,12 @@ def _resolve_runtime_from_pool_entry(
elif provider in ("opencode-zen", "opencode-go"):
from hermes_cli.models import opencode_model_api_mode
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
elif base_url.rstrip("/").endswith("/anthropic"):
api_mode = "anthropic_messages"
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
# api.openai.com → codex_responses, api.x.ai → codex_responses).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Strip the
@@ -642,8 +653,11 @@ def _resolve_explicit_runtime(
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode:
api_mode = configured_mode
elif base_url.rstrip("/").endswith("/anthropic"):
api_mode = "anthropic_messages"
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
return {
"provider": provider,
@@ -965,10 +979,13 @@ def resolve_runtime_provider(
elif provider in ("opencode-zen", "opencode-go"):
from hermes_cli.models import opencode_model_api_mode
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
# Auto-detect Anthropic-compatible endpoints by URL convention
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
elif base_url.rstrip("/").endswith("/anthropic"):
api_mode = "anthropic_messages"
else:
# Auto-detect Anthropic-compatible endpoints by URL convention
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
# plus api.openai.com → codex_responses and api.x.ai → codex_responses.
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
# Strip trailing /v1 for OpenCode Anthropic models (see comment above).
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
base_url = re.sub(r"/v1/?$", "", base_url)
+2 -2
View File
@@ -89,8 +89,8 @@ _DEFAULT_PROVIDER_MODELS = {
"grok-code-fast-1",
],
"gemini": [
"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
],
"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"],
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
def _coerce_timeout(raw: object) -> float | None:
try:
timeout = float(raw)
except (TypeError, ValueError):
return None
if timeout <= 0:
return None
return timeout
def get_provider_request_timeout(
provider_id: str, model: str | None = None
) -> float | None:
"""Return a configured provider request timeout in seconds, if any."""
if not provider_id:
return None
try:
from hermes_cli.config import load_config
except ImportError:
return None
config = load_config()
providers = config.get("providers", {}) if isinstance(config, dict) else {}
provider_config = (
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
)
if not isinstance(provider_config, dict):
return None
model_config = _get_model_config(provider_config, model)
if model_config is not None:
timeout = _coerce_timeout(model_config.get("timeout_seconds"))
if timeout is not None:
return timeout
return _coerce_timeout(provider_config.get("request_timeout_seconds"))
def get_provider_stale_timeout(
provider_id: str, model: str | None = None
) -> float | None:
"""Return a configured non-stream stale timeout in seconds, if any."""
if not provider_id:
return None
try:
from hermes_cli.config import load_config
except ImportError:
return None
config = load_config()
providers = config.get("providers", {}) if isinstance(config, dict) else {}
provider_config = (
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
)
if not isinstance(provider_config, dict):
return None
model_config = _get_model_config(provider_config, model)
if model_config is not None:
timeout = _coerce_timeout(model_config.get("stale_timeout_seconds"))
if timeout is not None:
return timeout
return _coerce_timeout(provider_config.get("stale_timeout_seconds"))
def _get_model_config(
provider_config: dict[str, object], model: str | None
) -> dict[str, object] | None:
if not model:
return None
models = provider_config.get("models", {})
model_config = models.get(model, {}) if isinstance(models, dict) else {}
if isinstance(model_config, dict):
return model_config
return None
+1 -3
View File
@@ -245,7 +245,7 @@ TIPS = [
"Three plugin types: general (tools/hooks), memory providers, and context engines.",
"hermes plugins install owner/repo installs plugins directly from GitHub.",
"8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.",
"Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.",
"Plugin hooks include pre/post_tool_call, pre/post_llm_call, and transform_terminal_output for output canonicalization.",
# --- Miscellaneous ---
"Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.",
@@ -323,7 +323,6 @@ TIPS = [
"GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
"Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
"The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
"Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
"hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
"File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
"Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
@@ -346,4 +345,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
return random.choice(TIPS)
+20 -2
View File
@@ -232,8 +232,8 @@ _CATEGORY_MERGE: Dict[str, str] = {
"checkpoints": "agent",
"approvals": "security",
"human_delay": "display",
"smart_model_routing": "agent",
"dashboard": "display",
"code_execution": "agent",
}
# Display order for tabs — unlisted categories sort alphabetically after these.
@@ -1958,6 +1958,8 @@ async def update_config_raw(body: RawConfigUpdate):
@app.get("/api/analytics/usage")
async def get_usage_analytics(days: int = 30):
from hermes_state import SessionDB
from agent.insights import InsightsEngine
db = SessionDB()
try:
cutoff = time.time() - (days * 86400)
@@ -1997,8 +1999,24 @@ async def get_usage_analytics(days: int = 30):
FROM sessions WHERE started_at > ?
""", (cutoff,))
totals = dict(cur3.fetchone())
insights_report = InsightsEngine(db).generate(days=days)
skills = insights_report.get("skills", {
"summary": {
"total_skill_loads": 0,
"total_skill_edits": 0,
"total_skill_actions": 0,
"distinct_skills_used": 0,
},
"top_skills": [],
})
return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days}
return {
"daily": daily,
"by_model": by_model,
"totals": totals,
"period_days": days,
"skills": skills,
}
finally:
db.close()
+15 -1
View File
@@ -155,6 +155,15 @@ def _cmd_subscribe(args):
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if getattr(args, "deliver_only", False):
if route["deliver"] == "log":
print(
"Error: --deliver-only requires --deliver to be a real target "
"(telegram, discord, slack, github_comment, etc.) — not 'log'."
)
return
route["deliver_only"] = True
if args.deliver_chat_id:
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
@@ -172,9 +181,12 @@ def _cmd_subscribe(args):
else:
print(" Events: (all)")
print(f" Deliver: {route['deliver']}")
if route.get("deliver_only"):
print(" Mode: direct delivery (no agent, zero LLM cost)")
if route.get("prompt"):
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
print(f" Prompt: {prompt_preview}")
label = "Message" if route.get("deliver_only") else "Prompt"
print(f" {label}: {prompt_preview}")
print(f"\n Configure your service to POST to the URL above.")
print(f" Use the secret for HMAC-SHA256 signature validation.")
print(f" The gateway must be running to receive events (hermes gateway run).\n")
@@ -192,6 +204,8 @@ def _cmd_list(args):
for name, route in subs.items():
events = ", ".join(route.get("events", [])) or "(all)"
deliver = route.get("deliver", "log")
if route.get("deliver_only"):
deliver = f"{deliver} (direct — no agent)"
desc = route.get("description", "")
print(f"{name}")
if desc:
+156 -6
View File
@@ -383,10 +383,19 @@ class SessionDB:
return session_id
def end_session(self, session_id: str, end_reason: str) -> None:
"""Mark a session as ended."""
"""Mark a session as ended.
No-ops when the session is already ended. The first end_reason wins:
compression-split sessions must keep their ``end_reason = 'compression'``
record even if a later stale ``end_session()`` call (e.g. from a
desynced CLI session_id after ``/resume`` or ``/branch``) targets them
with a different reason. Use ``reopen_session()`` first if you
intentionally need to re-end a closed session with a new reason.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?",
"UPDATE sessions SET ended_at = ?, end_reason = ? "
"WHERE id = ? AND ended_at IS NULL",
(time.time(), end_reason, session_id),
)
self._execute_write(_do)
@@ -714,6 +723,42 @@ class SessionDB:
return f"{base} #{max_num + 1}"
def get_compression_tip(self, session_id: str) -> Optional[str]:
"""Walk the compression-continuation chain forward and return the tip.
A compression continuation is a child session where:
1. The parent's ``end_reason = 'compression'``
2. The child was created AFTER the parent was ended (started_at >= ended_at)
The second condition distinguishes compression continuations from
delegate subagents or branch children, which can also have a
``parent_session_id`` but were created while the parent was still live.
Returns the session_id of the latest continuation in the chain, or the
input ``session_id`` if it isn't part of a compression chain (or if the
input itself doesn't exist).
"""
current = session_id
# Bound the walk defensively — compression chains this deep are
# pathological and shouldn't happen in practice. 100 = plenty.
for _ in range(100):
with self._lock:
cursor = self._conn.execute(
"SELECT id FROM sessions "
"WHERE parent_session_id = ? "
" AND started_at >= ("
" SELECT ended_at FROM sessions "
" WHERE id = ? AND end_reason = 'compression'"
" ) "
"ORDER BY started_at DESC LIMIT 1",
(current, current),
)
row = cursor.fetchone()
if row is None:
return current
current = row["id"]
return current
def list_sessions_rich(
self,
source: str = None,
@@ -721,6 +766,7 @@ class SessionDB:
limit: int = 20,
offset: int = 0,
include_children: bool = False,
project_compression_tips: bool = True,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -732,6 +778,14 @@ class SessionDB:
By default, child sessions (subagent runs, compression continuations)
are excluded. Pass ``include_children=True`` to include them.
With ``project_compression_tips=True`` (default), sessions that are
roots of compression chains are projected forward to their latest
continuation one logical conversation = one list entry, showing the
live continuation's id/message_count/title/last_active. This prevents
compressed continuations from being invisible to users while keeping
delegate subagents and branches hidden. Pass ``False`` to return the
raw root rows (useful for admin/debug UIs).
"""
where_clauses = []
params = []
@@ -782,8 +836,77 @@ class SessionDB:
s["preview"] = ""
sessions.append(s)
# Project compression roots forward to their tips. Each row whose
# end_reason is 'compression' has a continuation child; replace the
# surfaced fields (id, message_count, title, last_active, ended_at,
# end_reason, preview) with the tip's values so the list entry acts
# as the live conversation. Keep the root's started_at to preserve
# chronological ordering by original conversation start.
if project_compression_tips and not include_children:
projected = []
for s in sessions:
if s.get("end_reason") != "compression":
projected.append(s)
continue
tip_id = self.get_compression_tip(s["id"])
if tip_id == s["id"]:
projected.append(s)
continue
tip_row = self._get_session_rich_row(tip_id)
if not tip_row:
projected.append(s)
continue
# Preserve the root's started_at for stable sort order, but
# surface the tip's identity and activity data.
merged = dict(s)
for key in (
"id", "ended_at", "end_reason", "message_count",
"tool_call_count", "title", "last_active", "preview",
"model", "system_prompt",
):
if key in tip_row:
merged[key] = tip_row[key]
merged["_lineage_root_id"] = s["id"]
projected.append(merged)
sessions = projected
return sessions
def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Fetch a single session with the same enriched columns as
``list_sessions_rich`` (preview + last_active). Returns None if the
session doesn't exist.
"""
query = """
SELECT s.*,
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
WHERE s.id = ?
"""
with self._lock:
cursor = self._conn.execute(query, (session_id,))
row = cursor.fetchone()
if not row:
return None
s = dict(row)
raw = s.pop("_preview_raw", "").strip()
if raw:
text = raw[:60]
s["preview"] = text + ("..." if len(raw) > 60 else "")
else:
s["preview"] = ""
return s
# =========================================================================
# Message storage
# =========================================================================
@@ -1126,10 +1249,37 @@ class SessionDB:
try:
with self._lock:
ctx_cursor = self._conn.execute(
"""SELECT role, content FROM messages
WHERE session_id = ? AND id >= ? - 1 AND id <= ? + 1
ORDER BY id""",
(match["session_id"], match["id"], match["id"]),
"""WITH target AS (
SELECT session_id, timestamp, id
FROM messages
WHERE id = ?
)
SELECT role, content
FROM (
SELECT m.id, m.timestamp, m.role, m.content
FROM messages m
JOIN target t ON t.session_id = m.session_id
WHERE (m.timestamp < t.timestamp)
OR (m.timestamp = t.timestamp AND m.id < t.id)
ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1
)
UNION ALL
SELECT role, content
FROM messages
WHERE id = ?
UNION ALL
SELECT role, content
FROM (
SELECT m.id, m.timestamp, m.role, m.content
FROM messages m
JOIN target t ON t.session_id = m.session_id
WHERE (m.timestamp > t.timestamp)
OR (m.timestamp = t.timestamp AND m.id > t.id)
ORDER BY m.timestamp ASC, m.id ASC
LIMIT 1
)""",
(match["id"], match["id"]),
)
context_msgs = [
{"role": r["role"], "content": (r["content"] or "")[:200]}
+9 -3
View File
@@ -43,13 +43,16 @@ from dotenv import load_dotenv
load_dotenv()
def _effective_temperature_for_model(model: str) -> Optional[float]:
def _effective_temperature_for_model(
model: str,
base_url: Optional[str] = None,
) -> Optional[float]:
"""Return a fixed temperature for models with strict sampling contracts."""
try:
from agent.auxiliary_client import _fixed_temperature_for_model
except Exception:
return None
return _fixed_temperature_for_model(model)
return _fixed_temperature_for_model(model, base_url)
@@ -457,7 +460,10 @@ Complete the user's task step by step."""
"tools": self.tools,
"timeout": 300.0,
}
fixed_temperature = _effective_temperature_for_model(self.model)
fixed_temperature = _effective_temperature_for_model(
self.model,
str(getattr(self.client, "base_url", "") or ""),
)
if fixed_temperature is not None:
api_kwargs["temperature"] = fixed_temperature
+49
View File
@@ -282,6 +282,31 @@ def get_tool_definitions(
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
break
# Rebuild discord_server schema based on the bot's privileged intents
# (detected from GET /applications/@me) and the user's action allowlist
# in config. Hides actions the bot's intents don't support so the
# model never attempts them, and annotates fetch_messages when the
# MESSAGE_CONTENT intent is missing.
if "discord_server" in available_tool_names:
try:
from tools.discord_tool import get_dynamic_schema
dynamic = get_dynamic_schema()
except Exception: # pragma: no cover — defensive, fall back to static
dynamic = None
if dynamic is None:
# Tool filtered out entirely (empty allowlist or detection disabled
# the only remaining actions). Drop it from the schema list.
filtered_tools = [
t for t in filtered_tools
if t.get("function", {}).get("name") != "discord_server"
]
available_tool_names.discard("discord_server")
else:
for i, td in enumerate(filtered_tools):
if td.get("function", {}).get("name") == "discord_server":
filtered_tools[i] = {"type": "function", "function": dynamic}
break
# Strip web tool cross-references from browser_navigate description when
# web_search / web_extract are not available. The static schema says
# "prefer web_search or web_extract" which causes the model to hallucinate
@@ -525,6 +550,30 @@ def handle_function_call(
except Exception:
pass
# Generic tool-result canonicalization seam: plugins receive the
# final result string (JSON, usually) and may replace it by
# returning a string from transform_tool_result. Runs after
# post_tool_call (which stays observational) and before the result
# is appended back into conversation context. Fail-open; the first
# valid string return wins; non-string returns are ignored.
try:
from hermes_cli.plugins import invoke_hook
hook_results = invoke_hook(
"transform_tool_result",
tool_name=function_name,
args=function_args,
result=result,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
for hook_result in hook_results:
if isinstance(hook_result, str):
result = hook_result
break
except Exception:
pass
return result
except Exception as e:
@@ -7,7 +7,7 @@ license: MIT
metadata:
hermes:
tags: [telephony, phone, sms, mms, voice, twilio, bland.ai, vapi, calling, texting]
related_skills: [find-nearby, google-workspace, agentmail]
related_skills: [maps, google-workspace, agentmail]
category: productivity
---
@@ -57,32 +57,32 @@ Use the `ddgs` command via `terminal` when it exists. This is the preferred path
```bash
# Text search
ddgs text -k "python async programming" -m 5
ddgs text -q "python async programming" -m 5
# News search
ddgs news -k "artificial intelligence" -m 5
ddgs news -q "artificial intelligence" -m 5
# Image search
ddgs images -k "landscape photography" -m 10
ddgs images -q "landscape photography" -m 10
# Video search
ddgs videos -k "python tutorial" -m 5
ddgs videos -q "python tutorial" -m 5
# With region filter
ddgs text -k "best restaurants" -m 5 -r us-en
ddgs text -q "best restaurants" -m 5 -r us-en
# Recent results only (d=day, w=week, m=month, y=year)
ddgs text -k "latest AI news" -m 5 -t w
ddgs text -q "latest AI news" -m 5 -t w
# JSON output for parsing
ddgs text -k "fastapi tutorial" -m 5 -o json
ddgs text -q "fastapi tutorial" -m 5 -o json
```
### CLI Flags
| Flag | Description | Example |
|------|-------------|---------|
| `-k` | Keywords (query)**required** | `-k "search terms"` |
| `-q` | Query — **required** | `-q "search terms"` |
| `-m` | Max results | `-m 5` |
| `-r` | Region | `-r us-en` |
| `-t` | Time limit | `-t w` (week) |
@@ -189,7 +189,7 @@ DuckDuckGo returns titles, URLs, and snippets — not full page content. To get
CLI example:
```bash
ddgs text -k "fastapi deployment guide" -m 3 -o json
ddgs text -q "fastapi deployment guide" -m 3 -o json
```
Python example, only after verifying `ddgs` is installed in that runtime:
@@ -229,7 +229,7 @@ Then extract the best URL with `web_extract` or another content-retrieval tool.
- **Do not assume the CLI exists**: Check `command -v ddgs` before using it.
- **Do not assume `execute_code` can import `ddgs`**: `from ddgs import DDGS` may fail with `ModuleNotFoundError` unless that runtime was prepared separately.
- **Package name**: The package is `ddgs` (previously `duckduckgo-search`). Install with `pip install ddgs`.
- **Don't confuse `-k` and `-m`** (CLI): `-k` is for keywords, `-m` is for max results count.
- **Don't confuse `-q` and `-m`** (CLI): `-q` is for the query, `-m` is for max results count.
- **Empty results**: If `ddgs` returns nothing, it may be rate-limited. Wait a few seconds and retry.
## Validated With
@@ -25,4 +25,4 @@ if ! command -v ddgs &> /dev/null; then
exit 1
fi
ddgs text -k "$QUERY" -m "$MAX_RESULTS"
ddgs text -q "$QUERY" -m "$MAX_RESULTS"
+4 -4
View File
@@ -4,7 +4,7 @@
Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys.
## Architecture Decision
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta/openai/`
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta`
- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk
- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed
- Our own OAuth credentials — NOT sharing tokens with Gemini CLI
@@ -32,9 +32,9 @@ Add a first-class `gemini` provider that authenticates via Google OAuth, using t
- File locking for concurrent access (multiple agent sessions)
## API Integration
- Base URL: `https://generativelanguage.googleapis.com/v1beta/openai/`
- Auth: `Authorization: Bearer <access_token>` (passed as `api_key` to OpenAI SDK)
- api_mode: `chat_completions` (standard)
- Base URL: `https://generativelanguage.googleapis.com/v1beta`
- Auth: native Gemini API authentication handled by the provider adapter
- api_mode: `chat_completions` (standard facade over native transport)
- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc.
## Files to Create/Modify
+51
View File
@@ -0,0 +1,51 @@
# disk-cleanup
Auto-tracks and cleans up ephemeral files created during Hermes Agent
sessions — test scripts, temp outputs, cron logs, stale chrome profiles.
Scoped strictly to `$HERMES_HOME` and `/tmp/hermes-*`.
Originally contributed by [@LVT382009](https://github.com/LVT382009) as a
skill in PR #12212. Ported to the plugin system so the behaviour runs
automatically via `post_tool_call` and `on_session_end` hooks — the agent
never needs to remember to call a tool.
## How it works
| Hook | Behaviour |
|---|---|
| `post_tool_call` | When `write_file` / `terminal` / `patch` creates a file matching `test_*`, `tmp_*`, or `*.test.*` inside `HERMES_HOME`, track it silently as `test` / `temp` / `cron-output`. |
| `on_session_end` | If any test files were auto-tracked during this turn, run `quick` cleanup (no prompts). |
Deletion rules (same as the original PR):
| Category | Threshold | Confirmation |
|---|---|---|
| `test` | every session end | Never |
| `temp` | >7 days since tracked | Never |
| `cron-output` | >14 days since tracked | Never |
| empty dirs under HERMES_HOME | always | Never |
| `research` | >30 days, beyond 10 newest | Always (deep only) |
| `chrome-profile` | >14 days since tracked | Always (deep only) |
| files >500 MB | never auto | Always (deep only) |
## Slash command
```
/disk-cleanup status # breakdown + top-10 largest
/disk-cleanup dry-run # preview without deleting
/disk-cleanup quick # run safe cleanup now
/disk-cleanup deep # quick + list items needing prompt
/disk-cleanup track <path> <category> # manual tracking
/disk-cleanup forget <path> # stop tracking
```
## Safety
- `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*`
- Windows mounts (`/mnt/c` etc.) are rejected
- The state directory `$HERMES_HOME/disk-cleanup/` is itself excluded
- `$HERMES_HOME/logs/`, `memories/`, `sessions/`, `skills/`, `plugins/`,
and config files are never tracked
- Backup/restore is scoped to `tracked.json` — the plugin never touches
agent logs
- Atomic writes: `.tmp` → backup → rename
+316
View File
@@ -0,0 +1,316 @@
"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files.
Wires three behaviours:
1. ``post_tool_call`` hook inspects ``write_file`` and ``terminal``
tool results for newly-created paths matching test/temp patterns
under ``HERMES_HOME`` and tracks them silently. Zero agent
compliance required.
2. ``on_session_end`` hook when any test files were auto-tracked
during the just-finished turn, runs :func:`disk_cleanup.quick` and
logs a single line to ``$HERMES_HOME/disk-cleanup/cleanup.log``.
3. ``/disk-cleanup`` slash command manual ``status``, ``dry-run``,
``quick``, ``deep``, ``track``, ``forget``.
Replaces PR #12212's skill-plus-script design: the agent no longer
needs to remember to run commands.
"""
from __future__ import annotations
import logging
import re
import shlex
import threading
from pathlib import Path
from typing import Any, Dict, Optional, Set
from . import disk_cleanup as dg
logger = logging.getLogger(__name__)
# Per-task set of "test files newly tracked this turn". Keyed by task_id
# (or session_id as fallback) so on_session_end can decide whether to run
# cleanup. Guarded by a lock — post_tool_call can fire concurrently on
# parallel tool calls.
_recent_test_tracks: Dict[str, Set[str]] = {}
_lock = threading.Lock()
# Tool-call result shapes we can parse
_WRITE_FILE_PATH_KEY = "path"
_TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _tracker_key(task_id: str, session_id: str) -> str:
return task_id or session_id or "default"
def _record_track(task_id: str, session_id: str, path: Path, category: str) -> None:
"""Record that we tracked *path* as *category* during this turn."""
if category != "test":
return
key = _tracker_key(task_id, session_id)
with _lock:
_recent_test_tracks.setdefault(key, set()).add(str(path))
def _drain(task_id: str, session_id: str) -> Set[str]:
"""Pop the set of test paths tracked during this turn."""
key = _tracker_key(task_id, session_id)
with _lock:
return _recent_test_tracks.pop(key, set())
def _attempt_track(path_str: str, task_id: str, session_id: str) -> None:
"""Best-effort auto-track. Never raises."""
try:
p = Path(path_str).expanduser()
except Exception:
return
if not p.exists():
return
category = dg.guess_category(p)
if category is None:
return
newly = dg.track(str(p), category, silent=True)
if newly:
_record_track(task_id, session_id, p, category)
def _extract_paths_from_write_file(args: Dict[str, Any]) -> Set[str]:
path = args.get(_WRITE_FILE_PATH_KEY)
return {path} if isinstance(path, str) and path else set()
def _extract_paths_from_patch(args: Dict[str, Any]) -> Set[str]:
# The patch tool creates new files via the `mode="patch"` path too, but
# most of its use is editing existing files — we only care about new
# ephemeral creations, so treat patch conservatively and only pick up
# the single-file `path` arg. Track-then-cleanup is idempotent, so
# re-tracking an already-tracked file is a no-op (dedup in track()).
path = args.get("path")
return {path} if isinstance(path, str) and path else set()
def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]:
"""Best-effort: pull candidate filesystem paths from a terminal command
and its output, then let ``guess_category`` / ``is_safe_path`` filter.
"""
paths: Set[str] = set()
cmd = args.get("command") or ""
if isinstance(cmd, str) and cmd:
# Tokenise the command — catches `touch /tmp/hermes-x/test_foo.py`
try:
for tok in shlex.split(cmd, posix=True):
if tok.startswith(("/", "~")):
paths.add(tok)
except ValueError:
pass
# Only scan the result text if it's a reasonable size (avoid 50KB dumps).
if isinstance(result, str) and len(result) < 4096:
for match in _TERMINAL_PATH_REGEX.findall(result):
paths.add(match)
return paths
# ---------------------------------------------------------------------------
# Hooks
# ---------------------------------------------------------------------------
def _on_post_tool_call(
tool_name: str = "",
args: Optional[Dict[str, Any]] = None,
result: Any = None,
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
**_: Any,
) -> None:
"""Auto-track ephemeral files created by recent tool calls."""
if not isinstance(args, dict):
return
candidates: Set[str] = set()
if tool_name == "write_file":
candidates = _extract_paths_from_write_file(args)
elif tool_name == "patch":
candidates = _extract_paths_from_patch(args)
elif tool_name == "terminal":
candidates = _extract_paths_from_terminal(args, result if isinstance(result, str) else "")
else:
return
for path_str in candidates:
_attempt_track(path_str, task_id, session_id)
def _on_session_end(
session_id: str = "",
completed: bool = True,
interrupted: bool = False,
**_: Any,
) -> None:
"""Run quick cleanup if any test files were tracked during this turn."""
# Drain both task-level and session-level buckets. In practice only one
# is populated per turn; the other is empty.
drained_session = _drain("", session_id)
# Also drain any task-scoped buckets that happen to exist. This is a
# cheap sweep: if an agent spawned subagents (each with their own
# task_id) they'll have recorded into separate buckets; we want to
# cleanup them all at session end.
with _lock:
task_buckets = list(_recent_test_tracks.keys())
for key in task_buckets:
if key and key != session_id:
_recent_test_tracks.pop(key, None)
if not drained_session and not task_buckets:
return
try:
summary = dg.quick()
except Exception as exc:
logger.debug("disk-cleanup quick cleanup failed: %s", exc)
return
if summary["deleted"] or summary["empty_dirs"]:
dg._log(
f"AUTO_QUICK (session_end): deleted={summary['deleted']} "
f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}"
)
# ---------------------------------------------------------------------------
# Slash command
# ---------------------------------------------------------------------------
_HELP_TEXT = """\
/disk-cleanup ephemeral-file cleanup
Subcommands:
status Per-category breakdown + top-10 largest
dry-run Preview what quick/deep would delete
quick Run safe cleanup now (no prompts)
deep Run quick, then list items that need prompts
track <path> <category> Manually add a path to tracking
forget <path> Stop tracking a path (does not delete)
Categories: temp | test | research | download | chrome-profile | cron-output | other
All operations are scoped to HERMES_HOME and /tmp/hermes-*.
Test files are auto-tracked on write_file / terminal and auto-cleaned at session end.
"""
def _fmt_summary(summary: Dict[str, Any]) -> str:
base = (
f"[disk-cleanup] Cleaned {summary['deleted']} files + "
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}."
)
if summary.get("errors"):
base += f"\n {len(summary['errors'])} error(s); see cleanup.log."
return base
def _handle_slash(raw_args: str) -> Optional[str]:
argv = raw_args.strip().split()
if not argv or argv[0] in ("help", "-h", "--help"):
return _HELP_TEXT
sub = argv[0]
if sub == "status":
return dg.format_status(dg.status())
if sub == "dry-run":
auto, prompt = dg.dry_run()
auto_size = sum(i["size"] for i in auto)
prompt_size = sum(i["size"] for i in prompt)
lines = [
"Dry-run preview (nothing deleted):",
f" Auto-delete : {len(auto)} files ({dg.fmt_size(auto_size)})",
]
for item in auto:
lines.append(f" [{item['category']}] {item['path']}")
lines.append(
f" Needs prompt: {len(prompt)} files ({dg.fmt_size(prompt_size)})"
)
for item in prompt:
lines.append(f" [{item['category']}] {item['path']}")
lines.append(
f"\n Total potential: {dg.fmt_size(auto_size + prompt_size)}"
)
return "\n".join(lines)
if sub == "quick":
return _fmt_summary(dg.quick())
if sub == "deep":
# In-session deep can't prompt the user interactively — show what
# quick cleaned plus the items that WOULD need confirmation.
quick_summary = dg.quick()
_auto, prompt_items = dg.dry_run()
lines = [_fmt_summary(quick_summary)]
if prompt_items:
size = sum(i["size"] for i in prompt_items)
lines.append(
f"\n{len(prompt_items)} item(s) need confirmation "
f"({dg.fmt_size(size)}):"
)
for item in prompt_items:
lines.append(f" [{item['category']}] {item['path']}")
lines.append(
"\nRun `/disk-cleanup forget <path>` to skip, or delete "
"manually via terminal."
)
return "\n".join(lines)
if sub == "track":
if len(argv) < 3:
return "Usage: /disk-cleanup track <path> <category>"
path_arg = argv[1]
category = argv[2]
if category not in dg.ALLOWED_CATEGORIES:
return (
f"Unknown category '{category}'. "
f"Allowed: {sorted(dg.ALLOWED_CATEGORIES)}"
)
if dg.track(path_arg, category, silent=True):
return f"Tracked {path_arg} as '{category}'."
return (
f"Not tracked (already present, missing, or outside HERMES_HOME): "
f"{path_arg}"
)
if sub == "forget":
if len(argv) < 2:
return "Usage: /disk-cleanup forget <path>"
n = dg.forget(argv[1])
return (
f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}."
if n else f"Not found in tracking: {argv[1]}"
)
return f"Unknown subcommand: {sub}\n\n{_HELP_TEXT}"
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def register(ctx) -> None:
ctx.register_hook("post_tool_call", _on_post_tool_call)
ctx.register_hook("on_session_end", _on_session_end)
ctx.register_command(
"disk-cleanup",
handler=_handle_slash,
description="Track and clean up ephemeral Hermes session files.",
)
+496
View File
@@ -0,0 +1,496 @@
"""disk_cleanup — ephemeral file cleanup for Hermes Agent.
Library module wrapping the deterministic cleanup rules written by
@LVT382009 in PR #12212. The plugin ``__init__.py`` wires these
functions into ``post_tool_call`` and ``on_session_end`` hooks so
tracking and cleanup happen automatically the agent never needs to
call a tool or remember a skill.
Rules:
- test files delete immediately at task end (age >= 0)
- temp files delete after 7 days
- cron-output delete after 14 days
- empty dirs always delete (under HERMES_HOME)
- research keep 10 newest, prompt for older (deep only)
- chrome-profile prompt after 14 days (deep only)
- >500 MB files prompt always (deep only)
Scope: strictly HERMES_HOME and /tmp/hermes-*
Never touches: ~/.hermes/logs/ or any system directory.
"""
from __future__ import annotations
import json
import logging
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
from hermes_constants import get_hermes_home
except Exception: # pragma: no cover — plugin may load before constants resolves
import os
def get_hermes_home() -> Path: # type: ignore[no-redef]
val = (os.environ.get("HERMES_HOME") or "").strip()
return Path(val).resolve() if val else (Path.home() / ".hermes").resolve()
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
def get_state_dir() -> Path:
"""State dir — separate from ``$HERMES_HOME/logs/``."""
return get_hermes_home() / "disk-cleanup"
def get_tracked_file() -> Path:
return get_state_dir() / "tracked.json"
def get_log_file() -> Path:
"""Audit log — intentionally NOT under ``$HERMES_HOME/logs/``."""
return get_state_dir() / "cleanup.log"
# ---------------------------------------------------------------------------
# Path safety
# ---------------------------------------------------------------------------
def is_safe_path(path: Path) -> bool:
"""Accept only paths under HERMES_HOME or ``/tmp/hermes-*``.
Rejects Windows mounts (``/mnt/c`` etc.) and any system directory.
"""
hermes_home = get_hermes_home()
try:
path.resolve().relative_to(hermes_home)
return True
except (ValueError, OSError):
pass
# Allow /tmp/hermes-* explicitly
parts = path.parts
if len(parts) >= 3 and parts[1] == "tmp" and parts[2].startswith("hermes-"):
return True
return False
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
def _log(message: str) -> None:
try:
log_file = get_log_file()
log_file.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
with open(log_file, "a") as f:
f.write(f"[{ts}] {message}\n")
except OSError:
# Never let the audit log break the agent loop.
pass
# ---------------------------------------------------------------------------
# tracked.json — atomic read/write, backup scoped to tracked.json only
# ---------------------------------------------------------------------------
def load_tracked() -> List[Dict[str, Any]]:
"""Load tracked.json. Restores from ``.bak`` on corruption."""
tf = get_tracked_file()
tf.parent.mkdir(parents=True, exist_ok=True)
if not tf.exists():
return []
try:
return json.loads(tf.read_text())
except (json.JSONDecodeError, ValueError):
bak = tf.with_suffix(".json.bak")
if bak.exists():
try:
data = json.loads(bak.read_text())
_log("WARN: tracked.json corrupted — restored from .bak")
return data
except Exception:
pass
_log("WARN: tracked.json corrupted, no backup — starting fresh")
return []
def save_tracked(tracked: List[Dict[str, Any]]) -> None:
"""Atomic write: ``.tmp`` → backup old → rename."""
tf = get_tracked_file()
tf.parent.mkdir(parents=True, exist_ok=True)
tmp = tf.with_suffix(".json.tmp")
tmp.write_text(json.dumps(tracked, indent=2))
if tf.exists():
shutil.copy2(tf, tf.with_suffix(".json.bak"))
tmp.replace(tf)
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
ALLOWED_CATEGORIES = {
"temp", "test", "research", "download",
"chrome-profile", "cron-output", "other",
}
def fmt_size(n: float) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} PB"
# ---------------------------------------------------------------------------
# Track / forget
# ---------------------------------------------------------------------------
def track(path_str: str, category: str, silent: bool = False) -> bool:
"""Register a file for tracking. Returns True if newly tracked."""
if category not in ALLOWED_CATEGORIES:
_log(f"WARN: unknown category '{category}', using 'other'")
category = "other"
path = Path(path_str).resolve()
if not path.exists():
_log(f"SKIP: {path} (does not exist)")
return False
if not is_safe_path(path):
_log(f"REJECT: {path} (outside HERMES_HOME)")
return False
size = path.stat().st_size if path.is_file() else 0
tracked = load_tracked()
# Deduplicate
if any(item["path"] == str(path) for item in tracked):
return False
tracked.append({
"path": str(path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": category,
"size": size,
})
save_tracked(tracked)
_log(f"TRACKED: {path} ({category}, {fmt_size(size)})")
if not silent:
print(f"Tracked: {path} ({category}, {fmt_size(size)})")
return True
def forget(path_str: str) -> int:
"""Remove a path from tracking without deleting the file."""
p = Path(path_str).resolve()
tracked = load_tracked()
before = len(tracked)
tracked = [i for i in tracked if Path(i["path"]).resolve() != p]
removed = before - len(tracked)
if removed:
save_tracked(tracked)
_log(f"FORGOT: {p} ({removed} entries)")
return removed
# ---------------------------------------------------------------------------
# Dry run
# ---------------------------------------------------------------------------
def dry_run() -> Tuple[List[Dict], List[Dict]]:
"""Return (auto_delete_list, needs_prompt_list) without touching files."""
tracked = load_tracked()
now = datetime.now(timezone.utc)
auto: List[Dict] = []
prompt: List[Dict] = []
for item in tracked:
p = Path(item["path"])
if not p.exists():
continue
age = (now - datetime.fromisoformat(item["timestamp"])).days
cat = item["category"]
size = item["size"]
if cat == "test":
auto.append(item)
elif cat == "temp" and age > 7:
auto.append(item)
elif cat == "cron-output" and age > 14:
auto.append(item)
elif cat == "research" and age > 30:
prompt.append(item)
elif cat == "chrome-profile" and age > 14:
prompt.append(item)
elif size > 500 * 1024 * 1024:
prompt.append(item)
return auto, prompt
# ---------------------------------------------------------------------------
# Quick cleanup
# ---------------------------------------------------------------------------
def quick() -> Dict[str, Any]:
"""Safe deterministic cleanup — no prompts.
Returns: ``{"deleted": N, "empty_dirs": N, "freed": bytes,
"errors": [str, ...]}``.
"""
tracked = load_tracked()
now = datetime.now(timezone.utc)
deleted = 0
freed = 0
new_tracked: List[Dict] = []
errors: List[str] = []
for item in tracked:
p = Path(item["path"])
cat = item["category"]
if not p.exists():
_log(f"STALE: {p} (removed from tracking)")
continue
age = (now - datetime.fromisoformat(item["timestamp"])).days
should_delete = (
cat == "test"
or (cat == "temp" and age > 7)
or (cat == "cron-output" and age > 14)
)
if should_delete:
try:
if p.is_file():
p.unlink()
elif p.is_dir():
shutil.rmtree(p)
freed += item["size"]
deleted += 1
_log(f"DELETED: {p} ({cat}, {fmt_size(item['size'])})")
except OSError as e:
_log(f"ERROR deleting {p}: {e}")
errors.append(f"{p}: {e}")
new_tracked.append(item)
else:
new_tracked.append(item)
# Remove empty dirs under HERMES_HOME (but leave HERMES_HOME itself and
# a short list of well-known top-level state dirs alone — a fresh install
# has these empty, and deleting them would surprise the user).
hermes_home = get_hermes_home()
_PROTECTED_TOP_LEVEL = {
"logs", "memories", "sessions", "cron", "cronjobs",
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
"hermes-agent", "backups", "profiles", ".worktrees",
}
empty_removed = 0
try:
for dirpath in sorted(hermes_home.rglob("*"), reverse=True):
if not dirpath.is_dir() or dirpath == hermes_home:
continue
try:
rel_parts = dirpath.relative_to(hermes_home).parts
except ValueError:
continue
# Skip the well-known top-level state dirs themselves.
if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL:
continue
try:
if not any(dirpath.iterdir()):
dirpath.rmdir()
empty_removed += 1
_log(f"DELETED: {dirpath} (empty dir)")
except OSError:
pass
except OSError:
pass
save_tracked(new_tracked)
_log(
f"QUICK_SUMMARY: {deleted} files, {empty_removed} dirs, "
f"{fmt_size(freed)}"
)
return {
"deleted": deleted,
"empty_dirs": empty_removed,
"freed": freed,
"errors": errors,
}
# ---------------------------------------------------------------------------
# Deep cleanup (interactive — not called from plugin hooks)
# ---------------------------------------------------------------------------
def deep(
confirm: Optional[callable] = None,
) -> Dict[str, Any]:
"""Deep cleanup.
Runs :func:`quick` first, then asks the *confirm* callable for each
risky item (research > 30d beyond 10 newest, chrome-profile > 14d,
any file > 500 MB). *confirm(item)* must return True to delete.
Returns: ``{"quick": {...}, "deep_deleted": N, "deep_freed": bytes}``.
"""
quick_result = quick()
if confirm is None:
# No interactive confirmer — deep stops after the quick pass.
return {"quick": quick_result, "deep_deleted": 0, "deep_freed": 0}
tracked = load_tracked()
now = datetime.now(timezone.utc)
research, chrome, large = [], [], []
for item in tracked:
p = Path(item["path"])
if not p.exists():
continue
age = (now - datetime.fromisoformat(item["timestamp"])).days
cat = item["category"]
if cat == "research" and age > 30:
research.append(item)
elif cat == "chrome-profile" and age > 14:
chrome.append(item)
elif item["size"] > 500 * 1024 * 1024:
large.append(item)
research.sort(key=lambda x: x["timestamp"], reverse=True)
old_research = research[10:]
freed, count = 0, 0
to_remove: List[Dict] = []
for group in (old_research, chrome, large):
for item in group:
if confirm(item):
try:
p = Path(item["path"])
if p.is_file():
p.unlink()
elif p.is_dir():
shutil.rmtree(p)
to_remove.append(item)
freed += item["size"]
count += 1
_log(
f"DELETED: {p} ({item['category']}, "
f"{fmt_size(item['size'])})"
)
except OSError as e:
_log(f"ERROR deleting {item['path']}: {e}")
if to_remove:
remove_paths = {i["path"] for i in to_remove}
save_tracked([i for i in tracked if i["path"] not in remove_paths])
return {"quick": quick_result, "deep_deleted": count, "deep_freed": freed}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
def status() -> Dict[str, Any]:
"""Return per-category breakdown and top 10 largest tracked files."""
tracked = load_tracked()
cats: Dict[str, Dict] = {}
for item in tracked:
c = item["category"]
cats.setdefault(c, {"count": 0, "size": 0})
cats[c]["count"] += 1
cats[c]["size"] += item["size"]
existing = [
(i["path"], i["size"], i["category"])
for i in tracked if Path(i["path"]).exists()
]
existing.sort(key=lambda x: x[1], reverse=True)
return {
"categories": cats,
"top10": existing[:10],
"total_tracked": len(tracked),
}
def format_status(s: Dict[str, Any]) -> str:
"""Human-readable status string (for slash command output)."""
lines = [f"{'Category':<20} {'Files':>6} {'Size':>10}", "-" * 40]
cats = s["categories"]
for cat, d in sorted(cats.items(), key=lambda x: x[1]["size"], reverse=True):
lines.append(f"{cat:<20} {d['count']:>6} {fmt_size(d['size']):>10}")
if not cats:
lines.append("(nothing tracked yet)")
lines.append("")
lines.append("Top 10 largest tracked files:")
if not s["top10"]:
lines.append(" (none)")
else:
for rank, (path, size, cat) in enumerate(s["top10"], 1):
lines.append(f" {rank:>2}. {fmt_size(size):>8} [{cat}] {path}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Auto-categorisation from tool-call inspection
# ---------------------------------------------------------------------------
_TEST_PATTERNS = ("test_", "tmp_")
_TEST_SUFFIXES = (".test.py", ".test.js", ".test.ts", ".test.md")
def guess_category(path: Path) -> Optional[str]:
"""Return a category label for *path*, or None if we shouldn't track it.
Used by the ``post_tool_call`` hook to auto-track ephemeral files.
"""
if not is_safe_path(path):
return None
# Skip the state dir itself, logs, memory files, sessions, config.
hermes_home = get_hermes_home()
try:
rel = path.resolve().relative_to(hermes_home)
top = rel.parts[0] if rel.parts else ""
if top in {
"disk-cleanup", "logs", "memories", "sessions", "config.yaml",
"skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md",
"auth.json", "hermes-agent",
}:
return None
if top == "cron" or top == "cronjobs":
return "cron-output"
if top == "cache":
return "temp"
except ValueError:
# Path isn't under HERMES_HOME (e.g. /tmp/hermes-*) — fall through.
pass
name = path.name
if name.startswith(_TEST_PATTERNS):
return "test"
if any(name.endswith(sfx) for sfx in _TEST_SUFFIXES):
return "test"
return None
+7
View File
@@ -0,0 +1,7 @@
name: disk-cleanup
version: 2.0.0
description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required."
author: "@LVT382009 (original), NousResearch (plugin port)"
hooks:
- post_tool_call
- on_session_end
+680 -144
View File
File diff suppressed because it is too large Load Diff
+2 -14
View File
@@ -630,7 +630,7 @@ function Copy-ConfigTemplates {
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null
# Create .env
$envPath = "$HermesHome\.env"
@@ -735,19 +735,7 @@ function Install-NodeDeps {
Pop-Location
}
# Install WhatsApp bridge dependencies
$bridgeDir = "$InstallDir\scripts\whatsapp-bridge"
if (Test-Path "$bridgeDir\package.json") {
Write-Info "Installing WhatsApp bridge dependencies..."
Push-Location $bridgeDir
try {
npm install --silent 2>&1 | Out-Null
Write-Success "WhatsApp bridge dependencies installed"
} catch {
Write-Warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
}
Pop-Location
}
Pop-Location
}
+10 -19
View File
@@ -297,7 +297,7 @@ check_python() {
if command -v python >/dev/null 2>&1; then
PYTHON_PATH="$(command -v python)"
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
@@ -306,7 +306,7 @@ check_python() {
log_info "Installing Python via pkg..."
pkg install -y python >/dev/null
PYTHON_PATH="$(command -v python)"
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
return 0
fi
@@ -315,18 +315,17 @@ check_python() {
# Let uv handle Python — it can download and manage Python versions
# First check if a suitable Python is already available
if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
# Python not found — use uv to install it (no sudo needed!)
log_info "Python $PYTHON_VERSION not found, installing via uv..."
if $UV_CMD python install "$PYTHON_VERSION"; then
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
if "$UV_CMD" python install "$PYTHON_VERSION"; then
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
else
log_error "Failed to install Python $PYTHON_VERSION"
@@ -1052,7 +1051,7 @@ copy_config_templates() {
log_info "Setting up configuration files..."
# Create ~/.hermes directory structure (config at top level, code in subdir)
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills,whatsapp/session}
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills}
# Create .env at ~/.hermes/.env (top level, easy to find)
if [ ! -f "$HERMES_HOME/.env" ]; then
@@ -1122,7 +1121,7 @@ install_node_deps() {
if [ "$DISTRO" = "termux" ]; then
log_info "Skipping automatic Node/browser dependency setup on Termux"
log_info "Browser automation and WhatsApp bridge are not part of the tested Termux install path yet."
log_info "Browser automation is not part of the tested Termux install path yet."
log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
return 0
fi
@@ -1204,15 +1203,7 @@ install_node_deps() {
log_success "TUI dependencies installed"
fi
# Install WhatsApp bridge dependencies
if [ -f "$INSTALL_DIR/scripts/whatsapp-bridge/package.json" ]; then
log_info "Installing WhatsApp bridge dependencies..."
cd "$INSTALL_DIR/scripts/whatsapp-bridge"
npm install --silent 2>/dev/null || {
log_warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
}
log_success "WhatsApp bridge dependencies installed"
fi
}
run_setup_wizard() {
+40 -1
View File
@@ -66,7 +66,10 @@ AUTHOR_MAP = {
"104278804+Sertug17@users.noreply.github.com": "Sertug17",
"112503481+caentzminger@users.noreply.github.com": "caentzminger",
"258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
"sir_even@icloud.com": "sirEven",
"36056348+sirEven@users.noreply.github.com": "sirEven",
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
@@ -76,7 +79,18 @@ AUTHOR_MAP = {
"39405770+yyq4193@users.noreply.github.com": "yyq4193",
"Asunfly@users.noreply.github.com": "Asunfly",
"2500400+honghua@users.noreply.github.com": "honghua",
"462836+jplew@users.noreply.github.com": "jplew",
"nish3451@users.noreply.github.com": "nish3451",
"Mibayy@users.noreply.github.com": "Mibayy",
"mibayy@users.noreply.github.com": "Mibayy",
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
"nocoo@users.noreply.github.com": "nocoo",
"30841158+n-WN@users.noreply.github.com": "n-WN",
"leoyuan0099@gmail.com": "keyuyuan",
"bxzt2006@163.com": "Only-Code-A",
"i@troy-y.org": "TroyMitchell911",
"mygamez@163.com": "zhongyueming1121",
"hansnow@users.noreply.github.com": "hansnow",
# contributors (manual mapping from git names)
"ahmedsherif95@gmail.com": "asheriif",
"liujinkun@bytedance.com": "liujinkun2025",
@@ -94,14 +108,22 @@ AUTHOR_MAP = {
"xiewenxuan462@gmail.com": "yule975",
"yiweimeng.dlut@hotmail.com": "meng93",
"hakanerten02@hotmail.com": "teyrebaz33",
"linux2010@users.noreply.github.com": "Linux2010",
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
"alexazzjjtt@163.com": "alexzhu0",
"1180176+Swift42@users.noreply.github.com": "Swift42",
"ruzzgarcn@gmail.com": "Ruzzgar",
"alireza78.crypto@gmail.com": "alireza78a",
"brooklyn.bb.nicholson@gmail.com": "brooklynnicholson",
"withapurpose37@gmail.com": "StefanIsMe",
"4317663+helix4u@users.noreply.github.com": "helix4u",
"331214+counterposition@users.noreply.github.com": "counterposition",
"blspear@gmail.com": "BrennerSpear",
"akhater@gmail.com": "akhater",
"239876380+handsdiff@users.noreply.github.com": "handsdiff",
"hesapacicam112@gmail.com": "etherman-os",
"mark.ramsell@rivermounts.com": "mark-ramsell",
"taeng02@icloud.com": "taeng0204",
"gpickett00@gmail.com": "gpickett00",
"mcosma@gmail.com": "wakamex",
"clawdia.nash@proton.me": "clawdia-nash",
@@ -112,6 +134,7 @@ AUTHOR_MAP = {
"noonou7@gmail.com": "HenkDz",
"dean.kerr@gmail.com": "deankerr",
"socrates1024@gmail.com": "socrates1024",
"seanalt555@gmail.com": "Salt-555",
"satelerd@gmail.com": "satelerd",
"numman.ali@gmail.com": "nummanali",
"0xNyk@users.noreply.github.com": "0xNyk",
@@ -123,12 +146,14 @@ AUTHOR_MAP = {
"aryan@synvoid.com": "aryansingh",
"johnsonblake1@gmail.com": "blakejohnson",
"hcn518@gmail.com": "pedh",
"haileymarshall005@gmail.com": "haileymarshall",
"greer.guthrie@gmail.com": "g-guthrie",
"kennyx102@gmail.com": "bobashopcashier",
"shokatalishaikh95@gmail.com": "areu01or00",
"bryan@intertwinesys.com": "bryanyoung",
"christo.mitov@gmail.com": "christomitov",
"hermes@nousresearch.com": "NousResearch",
"hermes@noushq.ai": "benbarclay",
"chinmingcock@gmail.com": "ChimingLiu",
"openclaw@sparklab.ai": "openclaw",
"semihcvlk53@gmail.com": "Himess",
@@ -143,7 +168,7 @@ AUTHOR_MAP = {
"jack.47@gmail.com": "JackTheGit",
"dalvidjr2022@gmail.com": "Jr-kenny",
"m@statecraft.systems": "mbierling",
"balyan.sid@gmail.com": "balyansid",
"balyan.sid@gmail.com": "alt-glitch",
"oluwadareab12@gmail.com": "bennytimz",
"simon@simonmarcus.org": "simon-marcus",
"xowiekk@gmail.com": "Xowiek",
@@ -153,6 +178,10 @@ AUTHOR_MAP = {
"1115117931@qq.com": "aaronagent",
"1506751656@qq.com": "hqhq1025",
"364939526@qq.com": "luyao618",
"hgk324@gmail.com": "houziershi",
"176644217+PStarH@users.noreply.github.com": "PStarH",
"51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
"906014227@qq.com": "bingo906",
"aaronwong1999@icloud.com": "AaronWong1999",
"agents@kylefrench.dev": "DeployFaith",
"angelos@oikos.lan.home.malaiwah.com": "angelos",
@@ -175,6 +204,7 @@ AUTHOR_MAP = {
"duerzy@gmail.com": "duerzy",
"emozilla@nousresearch.com": "emozilla",
"fancydirty@gmail.com": "fancydirty",
"farion1231@gmail.com": "farion1231",
"floptopbot33@gmail.com": "flobo3",
"fontana.pedro93@gmail.com": "pefontana",
"francis.x.fitzpatrick@gmail.com": "fxfitz",
@@ -193,6 +223,7 @@ AUTHOR_MAP = {
"kagura.chen28@gmail.com": "kagura-agent",
"1342088860@qq.com": "youngDoo",
"kamil@gwozdz.me": "kamil-gwozdz",
"skmishra1991@gmail.com": "bugkill3r",
"karamusti912@gmail.com": "MustafaKara7",
"kira@ariaki.me": "kira-ariaki",
"knopki@duck.com": "knopki",
@@ -203,6 +234,7 @@ AUTHOR_MAP = {
"82095453+iacker@users.noreply.github.com": "iacker",
"sontianye@users.noreply.github.com": "sontianye",
"jackjin1997@users.noreply.github.com": "jackjin1997",
"1037461232@qq.com": "jackjin1997",
"danieldoderlein@users.noreply.github.com": "danieldoderlein",
"lrawnsley@users.noreply.github.com": "lrawnsley",
"taeuk178@users.noreply.github.com": "taeuk178",
@@ -211,6 +243,7 @@ AUTHOR_MAP = {
"ygd58@users.noreply.github.com": "ygd58",
"vominh1919@users.noreply.github.com": "vominh1919",
"iamagenius00@users.noreply.github.com": "iamagenius00",
"9219265+cresslank@users.noreply.github.com": "cresslank",
"trevmanthony@gmail.com": "trevthefoolish",
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
"centripetal-star@users.noreply.github.com": "centripetal-star",
@@ -268,9 +301,15 @@ AUTHOR_MAP = {
"asurla@nvidia.com": "anniesurla",
"limkuan24@gmail.com": "WideLee",
"aviralarora002@gmail.com": "AviArora02-commits",
"draixagent@gmail.com": "draix",
"junminliu@gmail.com": "JimLiu",
"jarvischer@gmail.com": "maxchernin",
"levantam.98.2324@gmail.com": "LVT382009",
"zhurongcheng@rcrai.com": "heykb",
"withapurpose37@gmail.com": "StefanIsMe",
"261797239+lumenradley@users.noreply.github.com": "lumenradley",
"166376523+sjz-ks@users.noreply.github.com": "sjz-ks",
"haileymarshall005@gmail.com": "haileymarshall",
}
@@ -338,7 +338,6 @@ Edit with `hermes config edit` or `hermes config set section.key value`.
| `memory` | `memory_enabled`, `user_profile_enabled`, `provider` |
| `security` | `tirith_enabled`, `website_blocklist` |
| `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` |
| `smart_model_routing` | `enabled`, `cheap_model` |
| `checkpoints` | `enabled`, `max_snapshots` (50) |
Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/configuration
+54
View File
@@ -0,0 +1,54 @@
# Attribution
This skill bundles code ported from a third-party MIT-licensed project.
All reuse is credited here.
## pixel-art-studio (Synero)
- Source: https://github.com/Synero/pixel-art-studio
- License: MIT
- Copyright: © Synero, MIT-licensed contributors
### What was ported
**`scripts/palettes.py`** — the `PALETTES` dict containing 23 named RGB
palettes (hardware and artistic). Values are reproduced verbatim from
`scripts/pixelart.py` of pixel-art-studio.
**`scripts/pixel_art_video.py`** — the 12 procedural animation init/draw pairs
(`stars`, `fireflies`, `leaves`, `dust_motes`, `sparkles`, `rain`,
`lightning`, `bubbles`, `embers`, `snowflakes`, `neon_pulse`, `heat_shimmer`)
and the `SCENES` → layer mapping. Ported from `scripts/pixelart_video.py`
with minor refactors:
- Names prefixed with `_` for private helpers (`_px`, `_pixel_cross`)
- `SCENE_ANIMATIONS` renamed to `SCENES` and restructured to hold layer
names (strings) instead of function-name strings resolved via `globals()`
- `generate_video()` split: the Pollinations text-to-image call was removed
(Hermes uses its own `image_generate` + `pixel_art()` pipeline for base
frames). Only the overlay + ffmpeg encoding remains.
- Frame directory is now a `tempfile.TemporaryDirectory` instead of
hand-managed cleanup.
- `ffmpeg` invocation switched from `os.system` to `subprocess.run(check=True)`
for safety.
### What was NOT ported
- Wu's Color Quantization (PIL's built-in `quantize` suffices)
- Sobel edge-aware downsampling (requires scipy; not worth the dep)
- Bayer / Atkinson dither (would need numpy reimplementation; kept scope tight)
- Pollinations text-to-image generation (`pixelart_image.py`,
`generate_base()` in `pixelart_video.py`) — Hermes has `image_generate`
### License compatibility
pixel-art-studio ships under the MIT License, which permits redistribution
with attribution. This skill preserves the original copyright notice here
and in the SKILL.md credits block. No code was relicensed.
---
## pixel-art skill itself
- License: MIT (inherits from hermes-agent repo)
- Original author of the skill shell: dodo-reach
- Expansion with palettes + video: Hermes Agent contributors
+217
View File
@@ -0,0 +1,217 @@
---
name: pixel-art
description: Convert images into retro pixel art with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, etc.), and animate them into short videos. Presets cover arcade, SNES, and 10+ era-correct looks. Use `clarify` to let the user pick a style before generating.
version: 2.0.0
author: dodo-reach
license: MIT
metadata:
hermes:
tags: [creative, pixel-art, arcade, snes, nes, gameboy, retro, image, video]
category: creative
credits:
- "Hardware palettes and animation loops ported from Synero/pixel-art-studio (MIT) — https://github.com/Synero/pixel-art-studio"
---
# Pixel Art
Convert any image into retro pixel art, then optionally animate it into a short
MP4 or GIF with era-appropriate effects (rain, fireflies, snow, embers).
Two scripts ship with this skill:
- `scripts/pixel_art.py` — photo → pixel-art PNG (Floyd-Steinberg dithering)
- `scripts/pixel_art_video.py` — pixel-art PNG → animated MP4 (+ optional GIF)
Each is importable or runnable directly. Presets snap to hardware palettes
when you want era-accurate colors (NES, Game Boy, PICO-8, etc.), or use
adaptive N-color quantization for arcade/SNES-style looks.
## When to Use
- User wants retro pixel art from a source image
- User asks for NES / Game Boy / PICO-8 / C64 / arcade / SNES styling
- User wants a short looping animation (rain scene, night sky, snow, etc.)
- Posters, album covers, social posts, sprites, characters, avatars
## Workflow
Before generating, confirm the style with the user. Different presets produce
very different outputs and regenerating is costly.
### Step 1 — Offer a style
Call `clarify` with 4 representative presets. Pick the set based on what the
user asked for — don't just dump all 14.
Default menu when the user's intent is unclear:
```python
clarify(
question="Which pixel-art style do you want?",
choices=[
"arcade — bold, chunky 80s cabinet feel (16 colors, 8px)",
"nes — Nintendo 8-bit hardware palette (54 colors, 8px)",
"gameboy — 4-shade green Game Boy DMG",
"snes — cleaner 16-bit look (32 colors, 4px)",
],
)
```
When the user already named an era (e.g. "80s arcade", "Gameboy"), skip
`clarify` and use the matching preset directly.
### Step 2 — Offer animation (optional)
If the user asked for a video/GIF, or the output might benefit from motion,
ask which scene:
```python
clarify(
question="Want to animate it? Pick a scene or skip.",
choices=[
"night — stars + fireflies + leaves",
"urban — rain + neon pulse",
"snow — falling snowflakes",
"skip — just the image",
],
)
```
Do NOT call `clarify` more than twice in a row. One for style, one for scene if
animation is on the table. If the user explicitly asked for a specific style
and scene in their message, skip `clarify` entirely.
### Step 3 — Generate
Run `pixel_art()` first; if animation was requested, chain into
`pixel_art_video()` on the result.
## Preset Catalog
| Preset | Era | Palette | Block | Best for |
|--------|-----|---------|-------|----------|
| `arcade` | 80s arcade | adaptive 16 | 8px | Bold posters, hero art |
| `snes` | 16-bit | adaptive 32 | 4px | Characters, detailed scenes |
| `nes` | 8-bit | NES (54) | 8px | True NES look |
| `gameboy` | DMG handheld | 4 green shades | 8px | Monochrome Game Boy |
| `gameboy_pocket` | Pocket handheld | 4 grey shades | 8px | Mono GB Pocket |
| `pico8` | PICO-8 | 16 fixed | 6px | Fantasy-console look |
| `c64` | Commodore 64 | 16 fixed | 8px | 8-bit home computer |
| `apple2` | Apple II hi-res | 6 fixed | 10px | Extreme retro, 6 colors |
| `teletext` | BBC Teletext | 8 pure | 10px | Chunky primary colors |
| `mspaint` | Windows MS Paint | 24 fixed | 8px | Nostalgic desktop |
| `mono_green` | CRT phosphor | 2 green | 6px | Terminal/CRT aesthetic |
| `mono_amber` | CRT amber | 2 amber | 6px | Amber monitor look |
| `neon` | Cyberpunk | 10 neons | 6px | Vaporwave/cyber |
| `pastel` | Soft pastel | 10 pastels | 6px | Kawaii / gentle |
Named palettes live in `scripts/palettes.py` (see `references/palettes.md` for
the complete list — 28 named palettes total). Any preset can be overridden:
```python
pixel_art("in.png", "out.png", preset="snes", palette="PICO_8", block=6)
```
## Scene Catalog (for video)
| Scene | Effects |
|-------|---------|
| `night` | Twinkling stars + fireflies + drifting leaves |
| `dusk` | Fireflies + sparkles |
| `tavern` | Dust motes + warm sparkles |
| `indoor` | Dust motes |
| `urban` | Rain + neon pulse |
| `nature` | Leaves + fireflies |
| `magic` | Sparkles + fireflies |
| `storm` | Rain + lightning |
| `underwater` | Bubbles + light sparkles |
| `fire` | Embers + sparkles |
| `snow` | Snowflakes + sparkles |
| `desert` | Heat shimmer + dust |
## Invocation Patterns
### Python (import)
```python
import sys
sys.path.insert(0, "/home/teknium/.hermes/skills/creative/pixel-art/scripts")
from pixel_art import pixel_art
from pixel_art_video import pixel_art_video
# 1. Convert to pixel art
pixel_art("/path/to/photo.jpg", "/tmp/pixel.png", preset="nes")
# 2. Animate (optional)
pixel_art_video(
"/tmp/pixel.png",
"/tmp/pixel.mp4",
scene="night",
duration=6,
fps=15,
seed=42,
export_gif=True,
)
```
### CLI
```bash
cd /home/teknium/.hermes/skills/creative/pixel-art/scripts
python pixel_art.py in.jpg out.png --preset gameboy
python pixel_art.py in.jpg out.png --preset snes --palette PICO_8 --block 6
python pixel_art_video.py out.png out.mp4 --scene night --duration 6 --gif
```
## Pipeline Rationale
**Pixel conversion:**
1. Boost contrast/color/sharpness (stronger for smaller palettes)
2. Posterize to simplify tonal regions before quantization
3. Downscale by `block` with `Image.NEAREST` (hard pixels, no interpolation)
4. Quantize with Floyd-Steinberg dithering — against either an adaptive
N-color palette OR a named hardware palette
5. Upscale back with `Image.NEAREST`
Quantizing AFTER downscale keeps dithering aligned with the final pixel grid.
Quantizing before would waste error-diffusion on detail that disappears.
**Video overlay:**
- Copies the base frame each tick (static background)
- Overlays stateless-per-frame particle draws (one function per effect)
- Encodes via ffmpeg `libx264 -pix_fmt yuv420p -crf 18`
- Optional GIF via `palettegen` + `paletteuse`
## Dependencies
- Python 3.9+
- Pillow (`pip install Pillow`)
- ffmpeg on PATH (only needed for video — Hermes installs package this)
## Pitfalls
- Pallet keys are case-sensitive (`"NES"`, `"PICO_8"`, `"GAMEBOY_ORIGINAL"`).
- Very small sources (<100px wide) collapse under 8-10px blocks. Upscale the
source first if it's tiny.
- Fractional `block` or `palette` will break quantization — keep them positive ints.
- Animation particle counts are tuned for ~640x480 canvases. On very large
images you may want a second pass with a different seed for density.
- `mono_green` / `mono_amber` force `color=0.0` (desaturate). If you override
and keep chroma, the 2-color palette can produce stripes on smooth regions.
- `clarify` loop: call it at most twice per turn (style, then scene). Don't
pepper the user with more picks.
## Verification
- PNG is created at the output path
- Clear square pixel blocks visible at the preset's block size
- Color count matches preset (eyeball the image or run `Image.open(p).getcolors()`)
- Video is a valid MP4 (`ffprobe` can open it) with non-zero size
## Attribution
Named hardware palettes and the procedural animation loops in `pixel_art_video.py`
are ported from [pixel-art-studio](https://github.com/Synero/pixel-art-studio)
(MIT). See `ATTRIBUTION.md` in this skill directory for details.
@@ -0,0 +1,49 @@
# Named Palettes
28 hardware-accurate and artistic palettes available to `pixel_art()`.
Palette values are sourced from `pixel-art-studio` (MIT) — see ATTRIBUTION.md in the skill root.
Usage: pass the palette name as `palette=` or let a preset select it.
```python
pixel_art("in.png", "out.png", preset="nes") # preset selects NES
pixel_art("in.png", "out.png", preset="custom", palette="PICO_8", block=6)
```
## Hardware Palettes
| Name | Colors | Source |
|------|--------|--------|
| `NES` | 54 | Nintendo NES |
| `C64` | 16 | Commodore 64 |
| `COMMODORE_64` | 16 | Commodore 64 (alt) |
| `ZX_SPECTRUM` | 8 | Sinclair ZX Spectrum |
| `APPLE_II_LO` | 16 | Apple II lo-res |
| `APPLE_II_HI` | 6 | Apple II hi-res |
| `GAMEBOY_ORIGINAL` | 4 | Game Boy DMG (green) |
| `GAMEBOY_POCKET` | 4 | Game Boy Pocket (grey) |
| `GAMEBOY_VIRTUALBOY` | 4 | Virtual Boy (red) |
| `PICO_8` | 16 | PICO-8 fantasy console |
| `TELETEXT` | 8 | BBC Teletext |
| `CGA_MODE4_PAL1` | 4 | IBM CGA |
| `MSX` | 15 | MSX |
| `MICROSOFT_WINDOWS_16` | 16 | Windows 3.x default |
| `MICROSOFT_WINDOWS_PAINT` | 24 | MS Paint classic |
| `MONO_BW` | 2 | Black and white |
| `MONO_AMBER` | 2 | Amber monochrome |
| `MONO_GREEN` | 2 | Green monochrome |
## Artistic Palettes
| Name | Colors | Feel |
|------|--------|------|
| `PASTEL_DREAM` | 10 | Soft pastels |
| `NEON_CYBER` | 10 | Cyberpunk neon |
| `RETRO_WARM` | 10 | Warm 70s |
| `OCEAN_DEEP` | 10 | Blue gradient |
| `FOREST_MOSS` | 10 | Green naturals |
| `SUNSET_FIRE` | 10 | Red to yellow |
| `ARCTIC_ICE` | 10 | Cool blues and whites |
| `VINTAGE_ROSE` | 10 | Rose mauves |
| `EARTH_CLAY` | 10 | Terracotta browns |
| `ELECTRIC_VIOLET` | 10 | Violet gradient |
@@ -0,0 +1,167 @@
"""Named RGB palettes for pixel_art() and pixel_art_video().
Palette RGB values sourced from pixel-art-studio (MIT License)
https://github.com/Synero/pixel-art-studio see ATTRIBUTION.md.
"""
PALETTES = {
# ── Hardware palettes ───────────────────────────────────────────────
"NES": [
(0, 0, 0), (124, 124, 124), (0, 0, 252), (0, 0, 188), (68, 40, 188),
(148, 0, 132), (168, 0, 32), (168, 16, 0), (136, 20, 0), (0, 116, 0),
(0, 148, 0), (0, 120, 0), (0, 88, 0), (0, 64, 88), (188, 188, 188),
(0, 120, 248), (0, 88, 248), (104, 68, 252), (216, 0, 204), (228, 0, 88),
(248, 56, 0), (228, 92, 16), (172, 124, 0), (0, 184, 0), (0, 168, 0),
(0, 168, 68), (0, 136, 136), (248, 248, 248), (60, 188, 252),
(104, 136, 252), (152, 120, 248), (248, 120, 248), (248, 88, 152),
(248, 120, 88), (252, 160, 68), (248, 184, 0), (184, 248, 24),
(88, 216, 84), (88, 248, 152), (0, 232, 216), (120, 120, 120),
(252, 252, 252), (164, 228, 252), (184, 184, 248), (216, 184, 248),
(248, 184, 248), (248, 164, 192), (240, 208, 176), (252, 224, 168),
(248, 216, 120), (216, 248, 120), (184, 248, 184), (184, 248, 216),
(0, 252, 252), (216, 216, 216),
],
"C64": [
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 191, 199),
(161, 87, 164), (92, 172, 95), (64, 64, 223), (191, 206, 137),
(161, 104, 60), (108, 80, 21), (203, 126, 117), (98, 98, 98),
(137, 137, 137), (154, 226, 155), (124, 124, 255), (173, 173, 173),
],
"COMMODORE_64": [
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 192, 200),
(161, 87, 165), (92, 172, 95), (64, 68, 227), (203, 214, 137),
(163, 104, 58), (110, 84, 11), (204, 127, 118), (99, 99, 99),
(139, 139, 139), (154, 227, 157), (139, 127, 205), (175, 175, 175),
],
"ZX_SPECTRUM": [
(0, 0, 0), (0, 39, 251), (252, 48, 22), (255, 63, 252),
(0, 249, 44), (0, 252, 254), (255, 253, 51), (255, 255, 255),
],
"APPLE_II_LO": [
(0, 0, 0), (133, 59, 81), (80, 71, 137), (234, 93, 240),
(0, 104, 82), (146, 146, 146), (0, 168, 241), (202, 195, 248),
(81, 92, 15), (235, 127, 35), (146, 146, 146), (246, 185, 202),
(0, 202, 41), (203, 211, 155), (155, 220, 203), (255, 255, 255),
],
"APPLE_II_HI": [
(0, 0, 0), (255, 0, 255), (0, 255, 0), (255, 255, 255),
(0, 175, 255), (255, 80, 0),
],
"GAMEBOY_ORIGINAL": [
(0, 63, 0), (46, 115, 32), (140, 191, 10), (160, 207, 10),
],
"GAMEBOY_POCKET": [
(0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255),
],
"GAMEBOY_VIRTUALBOY": [
(239, 0, 0), (164, 0, 0), (85, 0, 0), (0, 0, 0),
],
"PICO_8": [
(0, 0, 0), (29, 43, 83), (126, 37, 83), (0, 135, 81), (171, 82, 54),
(95, 87, 79), (194, 195, 199), (255, 241, 232), (255, 0, 77),
(255, 163, 0), (255, 236, 39), (0, 228, 54), (41, 173, 255),
(131, 118, 156), (255, 119, 168), (255, 204, 170),
],
"TELETEXT": [
(0, 0, 0), (255, 0, 0), (0, 128, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
],
"CGA_MODE4_PAL1": [
(0, 0, 0), (255, 255, 255), (0, 255, 255), (255, 0, 255),
],
"MSX": [
(0, 0, 0), (62, 184, 73), (116, 208, 125), (89, 85, 224),
(128, 118, 241), (185, 94, 81), (101, 219, 239), (219, 101, 89),
(255, 137, 125), (204, 195, 94), (222, 208, 135), (58, 162, 65),
(183, 102, 181), (204, 204, 204), (255, 255, 255),
],
"MICROSOFT_WINDOWS_16": [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128),
(128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128),
(255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255),
(255, 0, 255), (0, 255, 255), (255, 255, 255),
],
"MICROSOFT_WINDOWS_PAINT": [
(0, 0, 0), (255, 255, 255), (123, 123, 123), (189, 189, 189),
(123, 12, 2), (255, 37, 0), (123, 123, 2), (255, 251, 2),
(0, 123, 2), (2, 249, 2), (0, 123, 122), (2, 253, 254),
(2, 19, 122), (5, 50, 255), (123, 25, 122), (255, 64, 254),
(122, 57, 2), (255, 122, 57), (123, 123, 56), (255, 252, 122),
(2, 57, 57), (5, 250, 123), (0, 123, 255), (255, 44, 123),
],
"MONO_BW": [(0, 0, 0), (255, 255, 255)],
"MONO_AMBER": [(40, 40, 40), (255, 176, 0)],
"MONO_GREEN": [(40, 40, 40), (51, 255, 51)],
# ── Artistic palettes ───────────────────────────────────────────────
"PASTEL_DREAM": [
(255, 218, 233), (255, 229, 204), (255, 255, 204), (204, 255, 229),
(204, 229, 255), (229, 204, 255), (255, 204, 229), (204, 255, 255),
(255, 245, 220), (230, 230, 250),
],
"NEON_CYBER": [
(0, 0, 0), (255, 0, 128), (0, 255, 255), (255, 0, 255),
(0, 255, 128), (255, 255, 0), (128, 0, 255), (255, 128, 0),
(0, 128, 255), (255, 255, 255),
],
"RETRO_WARM": [
(62, 39, 35), (139, 69, 19), (210, 105, 30), (244, 164, 96),
(255, 218, 185), (255, 245, 238), (178, 34, 34), (205, 92, 92),
(255, 99, 71), (255, 160, 122),
],
"OCEAN_DEEP": [
(0, 25, 51), (0, 51, 102), (0, 76, 153), (0, 102, 178),
(0, 128, 204), (51, 153, 204), (102, 178, 204), (153, 204, 229),
(204, 229, 255), (229, 245, 255),
],
"FOREST_MOSS": [
(34, 51, 34), (51, 76, 51), (68, 102, 51), (85, 128, 68),
(102, 153, 85), (136, 170, 102), (170, 196, 136), (204, 221, 170),
(238, 238, 204), (245, 245, 220),
],
"SUNSET_FIRE": [
(51, 0, 0), (102, 0, 0), (153, 0, 0), (204, 0, 0), (255, 0, 0),
(255, 51, 0), (255, 102, 0), (255, 153, 0), (255, 204, 0),
(255, 255, 51),
],
"ARCTIC_ICE": [
(0, 0, 51), (0, 0, 102), (0, 51, 153), (0, 102, 153),
(51, 153, 204), (102, 204, 255), (153, 229, 255), (204, 242, 255),
(229, 247, 255), (255, 255, 255),
],
"VINTAGE_ROSE": [
(103, 58, 63), (137, 72, 81), (170, 91, 102), (196, 113, 122),
(219, 139, 147), (232, 168, 175), (240, 196, 199), (245, 215, 217),
(249, 232, 233), (255, 245, 245),
],
"EARTH_CLAY": [
(62, 39, 35), (89, 56, 47), (116, 73, 59), (143, 90, 71),
(170, 107, 83), (197, 124, 95), (210, 155, 126), (222, 186, 160),
(235, 217, 196), (248, 248, 232),
],
"ELECTRIC_VIOLET": [
(26, 0, 51), (51, 0, 102), (76, 0, 153), (102, 0, 204),
(128, 0, 255), (153, 51, 255), (178, 102, 255), (204, 153, 255),
(229, 204, 255), (245, 229, 255),
],
}
def build_palette_image(palette_name):
"""Build a 1x1 PIL 'P'-mode image with the named palette for Image.quantize(palette=...)."""
from PIL import Image
if palette_name not in PALETTES:
raise ValueError(
f"Unknown palette {palette_name!r}. "
f"Choose from: {sorted(PALETTES)}"
)
flat = []
for (r, g, b) in PALETTES[palette_name]:
flat.extend([r, g, b])
# Pad to 768 bytes (256 colors) as PIL requires
while len(flat) < 768:
flat.append(0)
pal_img = Image.new("P", (1, 1))
pal_img.putpalette(flat)
return pal_img
@@ -0,0 +1,162 @@
"""Pixel art converter — Floyd-Steinberg dithering with preset or named palette.
Named hardware palettes (NES, GameBoy, PICO-8, C64, etc.) ported from
pixel-art-studio (MIT) see ATTRIBUTION.md.
Usage (import):
from pixel_art import pixel_art
pixel_art("in.png", "out.png", preset="arcade")
pixel_art("in.png", "out.png", preset="nes")
pixel_art("in.png", "out.png", palette="PICO_8", block=6)
Usage (CLI):
python pixel_art.py in.png out.png --preset nes
"""
from PIL import Image, ImageEnhance, ImageOps
try:
from .palettes import PALETTES, build_palette_image
except ImportError:
from palettes import PALETTES, build_palette_image
PRESETS = {
# ── Original presets (adaptive palette) ─────────────────────────────
"arcade": {
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
"posterize_bits": 5, "block": 8, "palette": 16,
},
"snes": {
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 6, "block": 4, "palette": 32,
},
# ── Hardware-accurate presets (named palette) ───────────────────────
"nes": {
"contrast": 1.5, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "NES",
},
"gameboy": {
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_ORIGINAL",
},
"gameboy_pocket": {
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_POCKET",
},
"pico8": {
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
"posterize_bits": 6, "block": 6, "palette": "PICO_8",
},
"c64": {
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "C64",
},
"apple2": {
"contrast": 1.8, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 5, "block": 10, "palette": "APPLE_II_HI",
},
"teletext": {
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
"posterize_bits": 5, "block": 10, "palette": "TELETEXT",
},
"mspaint": {
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
"posterize_bits": 6, "block": 8, "palette": "MICROSOFT_WINDOWS_PAINT",
},
"mono_green": {
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "MONO_GREEN",
},
"mono_amber": {
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "MONO_AMBER",
},
# ── Artistic palette presets ────────────────────────────────────────
"neon": {
"contrast": 1.8, "color": 1.6, "sharpness": 1.2,
"posterize_bits": 5, "block": 6, "palette": "NEON_CYBER",
},
"pastel": {
"contrast": 1.2, "color": 1.3, "sharpness": 1.1,
"posterize_bits": 6, "block": 6, "palette": "PASTEL_DREAM",
},
}
def pixel_art(input_path, output_path, preset="arcade", **overrides):
"""Convert an image to retro pixel art.
Args:
input_path: path to source image
output_path: path to save the resulting PNG
preset: one of PRESETS (arcade, snes, nes, gameboy, pico8, c64, ...)
**overrides: optionally override any preset field. In particular:
palette: int (adaptive N colors) OR str (named palette from PALETTES)
block: int pixel block size
contrast / color / sharpness / posterize_bits: numeric enhancers
Returns:
The resulting PIL.Image.
"""
if preset not in PRESETS:
raise ValueError(
f"Unknown preset {preset!r}. Choose from: {sorted(PRESETS)}"
)
cfg = {**PRESETS[preset], **overrides}
img = Image.open(input_path).convert("RGB")
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
img = ImageEnhance.Color(img).enhance(cfg["color"])
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
img = ImageOps.posterize(img, cfg["posterize_bits"])
w, h = img.size
block = cfg["block"]
small = img.resize(
(max(1, w // block), max(1, h // block)),
Image.NEAREST,
)
# Quantize AFTER downscale so Floyd-Steinberg aligns with final pixel grid.
pal = cfg["palette"]
if isinstance(pal, str):
# Named hardware/artistic palette
pal_img = build_palette_image(pal)
quantized = small.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG)
else:
# Adaptive N-color palette (original behavior)
quantized = small.quantize(colors=int(pal), dither=Image.FLOYDSTEINBERG)
result = quantized.resize((w, h), Image.NEAREST)
result.save(output_path, "PNG")
return result
def main():
import argparse
p = argparse.ArgumentParser(description="Convert image to pixel art.")
p.add_argument("input")
p.add_argument("output")
p.add_argument("--preset", default="arcade", choices=sorted(PRESETS))
p.add_argument("--palette", default=None,
help=f"Override palette: int or name from {sorted(PALETTES)}")
p.add_argument("--block", type=int, default=None)
args = p.parse_args()
overrides = {}
if args.palette is not None:
try:
overrides["palette"] = int(args.palette)
except ValueError:
overrides["palette"] = args.palette
if args.block is not None:
overrides["block"] = args.block
pixel_art(args.input, args.output, preset=args.preset, **overrides)
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,345 @@
"""Pixel art video — overlay procedural animations onto a source image.
Takes any image (typically pre-processed with pixel_art()) and overlays
animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP4
(and optionally GIF) via ffmpeg.
Scene animations ported from pixel-art-studio (MIT) see ATTRIBUTION.md.
The generative/Pollinations code is intentionally dropped Hermes uses
`image_generate` + `pixel_art()` for base frames instead.
Usage (import):
from pixel_art_video import pixel_art_video
pixel_art_video("frame.png", "out.mp4", scene="night", duration=6)
Usage (CLI):
python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif
"""
import math
import os
import random
import shutil
import subprocess
import tempfile
from PIL import Image, ImageDraw
# ── Pixel drawing helpers ──────────────────────────────────────────────
def _px(draw, x, y, color, size=2):
x, y = int(x), int(y)
W, H = draw.im.size
if 0 <= x < W and 0 <= y < H:
draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color)
def _pixel_cross(draw, x, y, color, arm=2):
x, y = int(x), int(y)
for i in range(-arm, arm + 1):
_px(draw, x + i, y, color, 1)
_px(draw, x, y + i, color, 1)
# ── Animation init/draw pairs ──────────────────────────────────────────
def init_stars(rng, W, H):
return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)]
def draw_stars(draw, stars, t, W, H):
for i, (sx, sy) in enumerate(stars):
if math.sin(t * 2.0 + i * 0.7) > 0.65:
_pixel_cross(draw, sx, sy, (255, 255, 220), arm=2)
def init_fireflies(rng, W, H):
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20),
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)}
for _ in range(10)]
def draw_fireflies(draw, ff, t, W, H):
for f in ff:
if math.sin(t * 1.5 + f["phase"]) < 0.15:
continue
_px(draw,
f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3,
f["y"] + math.cos(t * f["speed"] * 0.7) * 2,
(200, 255, 100), 2)
def init_leaves(rng, W, H):
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
"speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05),
"phase": rng.uniform(0, 6.28),
"color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])}
for _ in range(12)]
def draw_leaves(draw, leaves, t, W, H):
for leaf in leaves:
_px(draw,
leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15,
(leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20,
leaf["color"], 2)
def init_dust_motes(rng, W, H):
return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30),
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5),
"amp": rng.uniform(2, 6)} for _ in range(20)]
def draw_dust_motes(draw, motes, t, W, H):
for m in motes:
if math.sin(t * 2.0 + m["phase"]) > 0.3:
_px(draw,
m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"],
m["y"] - (m["speed"] * t * 15) % H,
(255, 210, 100), 1)
def init_sparkles(rng, W, H):
return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4),
rng.uniform(0, 6.28),
rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)]))
for _ in range(10)]
def draw_sparkles(draw, sparkles, t, W, H):
for sx, sy, phase, color in sparkles:
if math.sin(t * 1.8 + phase) > 0.6:
_pixel_cross(draw, sx, sy, color, arm=2)
def init_rain(rng, W, H):
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
"speed": rng.uniform(4, 8)} for _ in range(30)]
def draw_rain(draw, rain, t, W, H):
for r in rain:
y = (r["y"] + t * r["speed"] * 20) % H
_px(draw, r["x"], y, (120, 150, 200), 1)
_px(draw, r["x"], y + 4, (100, 130, 180), 1)
def init_lightning(rng, W, H):
return {"timer": 0, "flash": False, "rng": rng}
def draw_lightning(draw, state, t, W, H):
state["timer"] += 1
if state["timer"] > 45 and state["rng"].random() < 0.04:
state["flash"] = True
state["timer"] = 0
if state["flash"]:
for x in range(0, W, 4):
for y in range(0, H // 3, 3):
if state["rng"].random() < 0.12:
_px(draw, x, y, (255, 255, 240), 2)
state["flash"] = False
def init_bubbles(rng, W, H):
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2),
"speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])}
for _ in range(15)]
def draw_bubbles(draw, bubbles, t, W, H):
for b in bubbles:
x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3
y = b["y"] - (t * b["speed"] * 20) % (H + 40)
if 0 < y < H:
_px(draw, x, y, (150, 200, 255), b["size"])
def init_embers(rng, W, H):
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
"speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28),
"color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])}
for _ in range(18)]
def draw_embers(draw, embers, t, W, H):
for e in embers:
x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5
y = e["y"] - (t * e["speed"] * 15) % H
if math.sin(t * 2.5 + e["phase"]) > 0.2:
_px(draw, x, y, e["color"], 2)
def init_snowflakes(rng, W, H):
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
"speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09),
"size": rng.choice([2, 2, 3])}
for _ in range(40)]
def draw_snowflakes(draw, flakes, t, W, H):
for f in flakes:
x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20
y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10
if f["size"] >= 3:
_pixel_cross(draw, x, y, (230, 235, 255), arm=1)
else:
_px(draw, x, y, (230, 235, 255), 2)
def init_neon_pulse(rng, W, H):
return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28),
rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)]))
for _ in range(8)]
def draw_neon_pulse(draw, points, t, W, H):
for x, y, phase, color in points:
if math.sin(t * 2.5 + phase) > 0.5:
_pixel_cross(draw, x, y, color, arm=3)
def init_heat_shimmer(rng, W, H):
return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H),
"phase": rng.uniform(0, 6.28)} for _ in range(12)]
def draw_heat_shimmer(draw, points, t, W, H):
for p in points:
x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2
y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1
if abs(math.sin(t * 1.5 + p["phase"])) > 0.6:
_px(draw, x, y, (255, 200, 100), 1)
# ── Scene → animation mapping ──────────────────────────────────────────
SCENES = {
"night": ["stars", "fireflies", "leaves"],
"dusk": ["fireflies", "sparkles"],
"tavern": ["dust_motes", "sparkles"],
"indoor": ["dust_motes"],
"urban": ["rain", "neon_pulse"],
"nature": ["leaves", "fireflies"],
"magic": ["sparkles", "fireflies"],
"storm": ["rain", "lightning"],
"underwater": ["bubbles", "sparkles"],
"fire": ["embers", "sparkles"],
"snow": ["snowflakes", "sparkles"],
"desert": ["heat_shimmer", "dust_motes"],
}
# Map scene layer name to (init_fn, draw_fn).
_LAYERS = {
"stars": (init_stars, draw_stars),
"fireflies": (init_fireflies, draw_fireflies),
"leaves": (init_leaves, draw_leaves),
"dust_motes": (init_dust_motes, draw_dust_motes),
"sparkles": (init_sparkles, draw_sparkles),
"rain": (init_rain, draw_rain),
"lightning": (init_lightning, draw_lightning),
"bubbles": (init_bubbles, draw_bubbles),
"embers": (init_embers, draw_embers),
"snowflakes": (init_snowflakes, draw_snowflakes),
"neon_pulse": (init_neon_pulse, draw_neon_pulse),
"heat_shimmer": (init_heat_shimmer, draw_heat_shimmer),
}
def _ensure_ffmpeg():
if shutil.which("ffmpeg") is None:
raise RuntimeError(
"ffmpeg not found on PATH. Install via your package manager or "
"download from https://ffmpeg.org/"
)
def pixel_art_video(
base_image,
output_path,
scene="night",
duration=6,
fps=15,
seed=None,
export_gif=False,
):
"""Overlay pixel animations onto a base image and encode to MP4.
Args:
base_image: path to source image (ideally already pixel-art styled)
output_path: path to MP4 output (GIF sibling written if export_gif=True)
scene: key from SCENES (night, urban, storm, snow, fire, ...)
duration: seconds of animation
fps: frames per second (default 15 for retro feel)
seed: optional int for reproducible animation placement
export_gif: also write a GIF alongside the MP4
Returns:
(mp4_path, gif_path_or_None)
"""
if scene not in SCENES:
raise ValueError(
f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}"
)
_ensure_ffmpeg()
base = Image.open(base_image).convert("RGB")
W, H = base.size
rng = random.Random(seed if seed is not None else 42)
layers = []
for name in SCENES[scene]:
init_fn, draw_fn = _LAYERS[name]
layers.append((draw_fn, init_fn(rng, W, H)))
n_frames = fps * duration
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir:
for frame_idx in range(n_frames):
canvas = base.copy()
draw = ImageDraw.Draw(canvas)
t = frame_idx / fps
for draw_fn, state in layers:
draw_fn(draw, state, t, W, H)
canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png"))
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error",
"-framerate", str(fps),
"-i", os.path.join(frames_dir, "frame_%04d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
output_path],
check=True,
)
gif_path = None
if export_gif:
gif_path = output_path.rsplit(".", 1)[0] + ".gif"
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error",
"-framerate", str(fps),
"-i", os.path.join(frames_dir, "frame_%04d.png"),
"-vf",
"scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
"-loop", "0",
gif_path],
check=True,
)
return output_path, gif_path
def main():
import argparse
p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.")
p.add_argument("base_image")
p.add_argument("output")
p.add_argument("--scene", default="night", choices=sorted(SCENES))
p.add_argument("--duration", type=int, default=6)
p.add_argument("--fps", type=int, default=15)
p.add_argument("--seed", type=int, default=None)
p.add_argument("--gif", action="store_true")
args = p.parse_args()
mp4, gif = pixel_art_video(
args.base_image, args.output,
scene=args.scene, duration=args.duration,
fps=args.fps, seed=args.seed, export_gif=args.gif,
)
print(f"Wrote {mp4}")
if gif:
print(f"Wrote {gif}")
if __name__ == "__main__":
main()
+26 -3
View File
@@ -1,10 +1,10 @@
---
name: webhook-subscriptions
description: Create and manage webhook subscriptions for event-driven agent activation. Use when the user wants external services to trigger agent runs automatically.
version: 1.0.0
description: Create and manage webhook subscriptions for event-driven agent activation, or for direct push notifications (zero LLM cost). Use when the user wants external services to trigger agent runs OR push notifications to chats.
version: 1.1.0
metadata:
hermes:
tags: [webhook, events, automation, integrations]
tags: [webhook, events, automation, integrations, notifications, push]
---
# Webhook Subscriptions
@@ -154,6 +154,29 @@ hermes webhook subscribe alerts \
--deliver origin
```
### Direct delivery (no agent, zero LLM cost)
For use cases where you just want to push a notification through to a user's chat — no reasoning, no agent loop — add `--deliver-only`. The rendered `--prompt` template becomes the literal message body and is dispatched directly to the target adapter.
Use this for:
- External service push notifications (Supabase/Firebase webhooks → Telegram)
- Monitoring alerts that should forward verbatim
- Inter-agent pings where one agent is telling another agent's user something
- Any webhook where an LLM round trip would be wasted effort
```bash
hermes webhook subscribe antenna-matches \
--deliver telegram \
--deliver-chat-id "123456789" \
--deliver-only \
--prompt "🎉 New match: {match.user_name} matched with you!" \
--description "Antenna match notifications"
```
The POST returns `200 OK` on successful delivery, `502` on target failure — so upstream services can retry intelligently. HMAC auth, rate limits, and idempotency still apply.
Requires `--deliver` to be a real target (telegram, discord, slack, github_comment, etc.) — `--deliver log` is rejected because log-only direct delivery is pointless.
## Security
- Each subscription gets an auto-generated HMAC-SHA256 secret (or provide your own with `--secret`)
-69
View File
@@ -1,69 +0,0 @@
---
name: find-nearby
description: Find nearby places (restaurants, cafes, bars, pharmacies, etc.) using OpenStreetMap. Works with coordinates, addresses, cities, zip codes, or Telegram location pins. No API keys needed.
version: 1.0.0
metadata:
hermes:
tags: [location, maps, nearby, places, restaurants, local]
related_skills: []
---
# Find Nearby — Local Place Discovery
Find restaurants, cafes, bars, pharmacies, and other places near any location. Uses OpenStreetMap (free, no API keys). Works with:
- **Coordinates** from Telegram location pins (latitude/longitude in conversation)
- **Addresses** ("near 123 Main St, Springfield")
- **Cities** ("restaurants in downtown Austin")
- **Zip codes** ("pharmacies near 90210")
- **Landmarks** ("cafes near Times Square")
## Quick Reference
```bash
# By coordinates (from Telegram location pin or user-provided)
python3 SKILL_DIR/scripts/find_nearby.py --lat <LAT> --lon <LON> --type restaurant --radius 1500
# By address, city, or landmark (auto-geocoded)
python3 SKILL_DIR/scripts/find_nearby.py --near "Times Square, New York" --type cafe
# Multiple place types
python3 SKILL_DIR/scripts/find_nearby.py --near "downtown austin" --type restaurant --type bar --limit 10
# JSON output
python3 SKILL_DIR/scripts/find_nearby.py --near "90210" --type pharmacy --json
```
### Parameters
| Flag | Description | Default |
|------|-------------|---------|
| `--lat`, `--lon` | Exact coordinates | — |
| `--near` | Address, city, zip, or landmark (geocoded) | — |
| `--type` | Place type (repeatable for multiple) | restaurant |
| `--radius` | Search radius in meters | 1500 |
| `--limit` | Max results | 15 |
| `--json` | Machine-readable JSON output | off |
### Common Place Types
`restaurant`, `cafe`, `bar`, `pub`, `fast_food`, `pharmacy`, `hospital`, `bank`, `atm`, `fuel`, `parking`, `supermarket`, `convenience`, `hotel`
## Workflow
1. **Get the location.** Look for coordinates (`latitude: ... / longitude: ...`) from a Telegram pin, or ask the user for an address/city/zip.
2. **Ask for preferences** (only if not already stated): place type, how far they're willing to go, any specifics (cuisine, "open now", etc.).
3. **Run the script** with appropriate flags. Use `--json` if you need to process results programmatically.
4. **Present results** with names, distances, and Google Maps links. If the user asked about hours or "open now," check the `hours` field in results — if missing or unclear, verify with `web_search`.
5. **For directions**, use the `directions_url` from results, or construct: `https://www.google.com/maps/dir/?api=1&origin=<LAT>,<LON>&destination=<LAT>,<LON>`
## Tips
- If results are sparse, widen the radius (1500 → 3000m)
- For "open now" requests: check the `hours` field in results, cross-reference with `web_search` for accuracy since OSM hours aren't always complete
- Zip codes alone can be ambiguous globally — prompt the user for country/state if results look wrong
- The script uses OpenStreetMap data which is community-maintained; coverage varies by region
@@ -1,184 +0,0 @@
#!/usr/bin/env python3
"""Find nearby places using OpenStreetMap (Overpass + Nominatim). No API keys needed.
Usage:
# By coordinates
python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --radius 1500
# By address/city/zip (auto-geocoded)
python find_nearby.py --near "Times Square, New York" --type cafe --radius 1000
python find_nearby.py --near "90210" --type pharmacy
# Multiple types
python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --type bar
# JSON output for programmatic use
python find_nearby.py --near "downtown las vegas" --type restaurant --json
"""
import argparse
import json
import math
import sys
import urllib.parse
import urllib.request
from typing import Any
OVERPASS_URLS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
USER_AGENT = "HermesAgent/1.0 (find-nearby skill)"
TIMEOUT = 15
def _http_get(url: str) -> Any:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read())
def _http_post(url: str, data: str) -> Any:
req = urllib.request.Request(
url, data=data.encode(), headers={"User-Agent": USER_AGENT}
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read())
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distance in meters between two coordinates."""
R = 6_371_000
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def geocode(query: str) -> tuple[float, float]:
"""Convert address/city/zip to coordinates via Nominatim."""
params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1})
results = _http_get(f"{NOMINATIM_URL}?{params}")
if not results:
print(f"Error: Could not geocode '{query}'. Try a more specific address.", file=sys.stderr)
sys.exit(1)
return float(results[0]["lat"]), float(results[0]["lon"])
def find_nearby(lat: float, lon: float, types: list[str], radius: int = 1500, limit: int = 15) -> list[dict]:
"""Query Overpass for nearby amenities."""
# Build Overpass QL query
type_filters = "".join(
f'nwr["amenity"="{t}"](around:{radius},{lat},{lon});' for t in types
)
query = f"[out:json][timeout:{TIMEOUT}];({type_filters});out center tags;"
# Try each Overpass server
data = None
for url in OVERPASS_URLS:
try:
data = _http_post(url, f"data={urllib.parse.quote(query)}")
break
except Exception:
continue
if not data:
return []
# Parse results
places = []
for el in data.get("elements", []):
tags = el.get("tags", {})
name = tags.get("name")
if not name:
continue
# Get coordinates (nodes have lat/lon directly, ways/relations use center)
plat = el.get("lat") or (el.get("center", {}) or {}).get("lat")
plon = el.get("lon") or (el.get("center", {}) or {}).get("lon")
if plat is None or plon is None:
continue
dist = haversine(lat, lon, plat, plon)
place = {
"name": name,
"type": tags.get("amenity", ""),
"distance_m": round(dist),
"lat": plat,
"lon": plon,
"maps_url": f"https://www.google.com/maps/search/?api=1&query={plat},{plon}",
"directions_url": f"https://www.google.com/maps/dir/?api=1&origin={lat},{lon}&destination={plat},{plon}",
}
# Add useful optional fields
if tags.get("cuisine"):
place["cuisine"] = tags["cuisine"]
if tags.get("opening_hours"):
place["hours"] = tags["opening_hours"]
if tags.get("phone"):
place["phone"] = tags["phone"]
if tags.get("website"):
place["website"] = tags["website"]
if tags.get("addr:street"):
addr_parts = [tags.get("addr:housenumber", ""), tags.get("addr:street", "")]
if tags.get("addr:city"):
addr_parts.append(tags["addr:city"])
place["address"] = " ".join(p for p in addr_parts if p)
places.append(place)
# Sort by distance, limit results
places.sort(key=lambda p: p["distance_m"])
return places[:limit]
def main():
parser = argparse.ArgumentParser(description="Find nearby places via OpenStreetMap")
parser.add_argument("--lat", type=float, help="Latitude")
parser.add_argument("--lon", type=float, help="Longitude")
parser.add_argument("--near", type=str, help="Address, city, or zip code (geocoded automatically)")
parser.add_argument("--type", action="append", dest="types", default=[], help="Place type (restaurant, cafe, bar, pharmacy, etc.)")
parser.add_argument("--radius", type=int, default=1500, help="Search radius in meters (default: 1500)")
parser.add_argument("--limit", type=int, default=15, help="Max results (default: 15)")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
args = parser.parse_args()
# Resolve coordinates
if args.near:
lat, lon = geocode(args.near)
elif args.lat is not None and args.lon is not None:
lat, lon = args.lat, args.lon
else:
print("Error: Provide --lat/--lon or --near", file=sys.stderr)
sys.exit(1)
if not args.types:
args.types = ["restaurant"]
places = find_nearby(lat, lon, args.types, args.radius, args.limit)
if args.json_output:
print(json.dumps({"origin": {"lat": lat, "lon": lon}, "results": places, "count": len(places)}, indent=2))
else:
if not places:
print(f"No {'/'.join(args.types)} found within {args.radius}m")
return
print(f"Found {len(places)} places within {args.radius}m:\n")
for i, p in enumerate(places, 1):
dist_str = f"{p['distance_m']}m" if p["distance_m"] < 1000 else f"{p['distance_m']/1000:.1f}km"
print(f" {i}. {p['name']} ({p['type']}) — {dist_str}")
if p.get("cuisine"):
print(f" Cuisine: {p['cuisine']}")
if p.get("hours"):
print(f" Hours: {p['hours']}")
if p.get("address"):
print(f" Address: {p['address']}")
print(f" Map: {p['maps_url']}")
print()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,3 +1,3 @@
---
description: Skills for working with MCP (Model Context Protocol) servers, tools, and integrations. Includes the built-in native MCP client (configure servers in config.yaml for automatic tool discovery) and the mcporter CLI bridge for ad-hoc server interaction.
description: Skills for working with MCP (Model Context Protocol) servers, tools, and integrations. Documents the built-in native MCP client configure servers in config.yaml for automatic tool discovery.
---

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