Compare commits

...

42 Commits

Author SHA1 Message Date
helix4u 1d24cb0e6e fix(tui): bound live render memory pressure 2026-04-26 12:55:06 -07:00
Zainan Victor Zhou 778fd1898e fix(slack): surface attachment access diagnostics
Translate Slack attachment failures into actionable user-facing notices
instead of generic download errors. When a scope/auth/permission issue
breaks attachment processing, the user sees:

  [Slack attachment notice]
  - Slack attachment access failed for photo.jpg. Missing scope:
    files:read. Update the Slack app scopes/settings and reinstall
    the app to the workspace.

Two helpers do the translation:

  _describe_slack_api_error — handles SlackApiError responses
    (missing_scope, invalid_auth, file_not_found, access_denied, etc.)

  _describe_slack_download_failure — handles httpx.HTTPStatusError
    (401/403/404) and Slack-returns-HTML-sign-in fallbacks

Wired into three existing call sites:
 - the Slack Connect files.info path (PR #11111) so scope errors
   surface instead of being logged as generic "files.info failed"
 - the image, audio, and document download paths so 401/403 and
   HTML-body responses translate into actionable notices

Adjustment from original PR: dropped _probe_slack_file_access_issue,
the proactive pre-download files.info probe. It added one extra
Slack API call per attachment even on healthy ones, and overlapped
with the existing files.info call from PR #11111. The post-failure
translation path covers the same user-facing diagnostic value
without the per-message tax.

Also documents files:read scope more prominently in the Slack setup
guide and troubleshooting table.

Contributed back from https://github.com/xinbenlv/zn-hermes-agent.

Closes #7015.
Co-authored-by: xinbenlv <zzn+pa@zzn.im>
2026-04-26 12:47:43 -07:00
Teknium 45bfcb9e71 test: update bare-agent helper for live-runtime attrs added by #16099
Background review fork now inherits session_id, credential_pool, and
status_callback from the parent (added in #16099 after this PR was
written). Extend the bare-agent helper so the regression test keeps
reaching the cleanup assertions instead of failing in the runtime
resolver.

Signed-off-by: Teknium <8425893+teknium1@users.noreply.github.com>
2026-04-26 12:45:39 -07:00
MRHwick aa7b5acfcd pass attribution check 2026-04-26 12:45:39 -07:00
MRHwick 36e352afa7 preserve the original comment 2026-04-26 12:45:39 -07:00
MRHwick 2d86e97a7e fix(run_agent): shut down background review memory providers
Temporary background review agents can initialize Hindsight-backed memory clients, but close() alone skips provider teardown. Shut the memory provider down before closing so aiohttp sessions do not leak at process exit.

Made-with: Cursor
2026-04-26 12:45:39 -07:00
Teknium edadeaf495 chore(release): map Satoshi-agi and kunlabs in AUTHOR_MAP 2026-04-26 12:35:16 -07:00
kunlabs f9885130b4 fix(slack): download files in Slack Connect channels
Slack Connect channels return file objects with file_access="check_file_info"
and no url_private_download field (see
https://docs.slack.dev/reference/objects/file-object/#slack_connect_files).
These stub objects must be resolved via files.info before download can
proceed. Without this the agent silently skips attachments posted in
Slack Connect channels.

Call files.info on every file whose file_access is check_file_info,
replace the stub with the full file object, and let the existing
download path continue. Warn and skip on files.info failures.

Closes #11095.
2026-04-26 12:35:16 -07:00
flobo3 f414df3a56 fix(slack): include team_id in thread-context cache key 2026-04-26 12:35:16 -07:00
Satoshi-agi c0d25df311 fix(slack): preserve thread-parent context when cron/bot posted the parent
The Slack thread-context fetcher used to drop every message with a
bot_id, which silently erased the thread parent whenever a cron job (or
any other bot) had posted it. As a result, replies to a cron-posted
summary lost all context and the agent answered as if from a blank
thread.

Changes:

1. gateway/platforms/slack.py::_fetch_thread_context
   - Keep the thread parent even when it was posted by a bot
     (e.g. cron summaries, third-party integrations).
   - Only skip *our own* prior bot replies to avoid circular context,
     matching the per-workspace bot user id via _team_bot_user_ids so
     multi-workspace deployments stay correct.
   - Keep non-self bot children (useful third-party context).

2. gateway/platforms/slack.py::_handle_slack_message
   - Populate MessageEvent.reply_to_text for thread replies (parity
     with Telegram/Discord/Feishu/WeCom). gateway.run uses this field
     to inject a [Replying to: "..."] prefix when the parent is not
     already in the session history, which is exactly the scenario
     triggered by cron-generated thread parents.
   - New helper _fetch_thread_parent_text reuses the existing thread-
     context cache (and its 60s TTL) to avoid duplicate
     conversations.replies calls; falls back to a cheap limit=1 fetch
     when the cache is cold.

Tests:

- Updated TestSlackThreadContext::test_skips_bot_messages to reflect
  the new behaviour (self-bot child dropped, third-party bot kept).
- Added:
    * test_fetch_thread_context_includes_bot_parent
    * test_fetch_thread_context_excludes_self_bot_replies
    * test_fetch_thread_context_multi_workspace
    * test_fetch_thread_context_current_ts_excluded (regression guard)
    * test_fetch_thread_parent_text_from_cache
    * test_slack_reply_to_text_set_on_thread_reply
    * test_slack_reply_to_text_none_for_top_level_message

Full Slack suite: 176 passed (was 169).
2026-04-26 12:35:16 -07:00
helix4u 10e36188da fix(cli): wire approvals in background tasks 2026-04-26 12:29:48 -07:00
Teknium 6a3102f9d4 chore(release): map hhuang91 in AUTHOR_MAP 2026-04-26 12:29:02 -07:00
bde3249023 75d3eaa0e4 fix(slack): exclude U/W user IDs from explicit target regex
Slack's chat.postMessage API rejects user IDs (U...) and workspace
IDs (W...) — they are not valid conversation IDs. Posting to them
fails because the API requires a channel ID (C/G/D). To DM a user,
the sender must first call conversations.open to obtain a D... ID.

Tighten _SLACK_TARGET_RE from [CGDUW] to [CGD] so the send path rejects
U/W values as explicit targets and instead falls through to channel-
name resolution (where they'll fail with a clear 'could not resolve'
error rather than silently getting stuck in a retry loop on the API).

Flip the corresponding regression test to assert U/W values are not
explicit. Matches the narrower regex briandevans proposed in #15939.

Co-authored-by: briandevans <brian@bde.io>
2026-04-26 12:29:02 -07:00
hhuang91 802c7acb81 fix(Slack): resolve Slack channels by raw ID and enumerate joined channels
send_message(target='slack:<channel_id>') failed with "Could not
resolve" because _parse_target_ref had no Slack branch — Slack's
uppercase alphanumeric IDs fell through to channel-name resolution,
which only matched by name. As a fallback, the agent would retry with
bare target='slack' and post to the home channel instead.

Three fixes:

- _parse_target_ref recognizes Slack IDs (C/G/D/U/W prefix) as
  explicit targets so the name-resolver is bypassed entirely.
- resolve_channel_name tries a case-sensitive raw-ID match before
  the existing name match, so any platform's IDs resolve cleanly.
- _build_slack now actually calls users.conversations against each
  workspace's AsyncWebClient (paginated), instead of only returning
  session-history entries. This populates the directory with public
  and private channels the bot has joined, so action='list' shows
  them and they can also be addressed by name. Errors from one
  workspace don't block others.

build_channel_directory becomes async (Slack web calls require it).
The two async-context callers in gateway/run.py are awaited; the
cron ticker thread call bridges via asyncio.run_coroutine_threadsafe.

Slack bot needs channels:read and groups:read scopes for full
enumeration; missing scopes degrade gracefully per-workspace.

addressing #15927
2026-04-26 12:29:02 -07:00
Teknium 541cd732e8 chore(models): drop deepseek from OpenRouter and Nous Portal curated picker lists (#16197)
Removes deepseek/deepseek-v4-pro and deepseek/deepseek-v4-flash from
OPENROUTER_MODELS and _PROVIDER_MODELS['nous'], then regenerates
website/static/api/model-catalog.json so the hosted picker JSON drops
them too. Direct-API deepseek provider support is unchanged.
2026-04-26 12:28:17 -07:00
Teknium 4d119bb62a test: blank platform-gating env vars in hermetic fixture
load_gateway_config() has a side effect: when config.yaml contains
platform-gating keys (slack.require_mention, slack.strict_mention,
slack.free_response_channels, slack.allow_bots, slack.reactions, plus
analogous keys for discord/telegram/whatsapp/dingtalk/matrix), it calls
os.environ[KEY] = ... to bridge them to env-var form.

monkeypatch.delenv doesn't track direct os.environ mutations made
inside the test body, so tests that call load_gateway_config() leak
those env vars into later tests on the same xdist worker. The failure
mode is flaky seed-dependent: test_top_level_message_requires_mention_
even_with_session (and siblings in TestThreadReplyHandling) pass when
SLACK_REQUIRE_MENTION is unset but fail when a leaked value of 'false'
is present.

Add the gating env vars to _HERMES_BEHAVIORAL_VARS so the hermetic
autouse fixture blanks them on every test setup, closing the leak
regardless of which test sets them.
2026-04-26 12:23:20 -07:00
Teknium 878c196738 chore(release): map hhhonzik in AUTHOR_MAP 2026-04-26 12:23:20 -07:00
Honza Stepanovsky 50dd67c680 fix(slack): skip _mentioned_threads registration when strict_mention is on
Extends the strict_mention feature so an @mention in strict mode no
longer persistently tags the thread as 'mentioned'. Without this, the
thread's first mention would permanently auto-trigger the bot on every
subsequent message — which is exactly what strict_mention is designed
to prevent. Closes the agent-to-agent ack loop hole hhhonzik identified
in #14117.

Co-authored-by: hhhonzik <me@janstepanovsky.cz>
2026-04-26 12:23:20 -07:00
Ching aea4a90f0e feat(slack): add opt-in slack.strict_mention gate for channel threads
Adds a strict_mention config option that, when enabled, requires an
explicit @-mention on every message in channel threads. Disables the
'once mentioned, forever in the thread' and session-presence auto-triggers.

- New _slack_strict_mention() helper (config.extra + SLACK_STRICT_MENTION env)
- Bridged top-level slack.strict_mention yaml to SLACK_STRICT_MENTION env,
  matching require_mention/allow_bots bridging
- Unit tests for the helper + config bridge
2026-04-26 12:23:20 -07:00
Teknium 897dc3a2bb fix(install+update): add /usr/local/bin PATH guard for RHEL root non-login shells (#16191)
* fix(install): add /usr/local/bin PATH guard for RHEL root non-login shells

The FHS-layout branch assumed /usr/local/bin is on PATH for every
standard shell. That holds for login shells (via /etc/profile's
pathmunge) but breaks on RHEL/CentOS/Rocky/Alma 8+ root in non-login
interactive shells (su, sudo -s, tmux panes, some web terminals) —
/etc/bashrc does not add /usr/local/bin and /root/.bash_profile
doesn't either. Result: hermes command links to /usr/local/bin/hermes
but the user has to type the absolute path each time.

Probe a fresh 'bash -i -c' (non-login interactive, matching the user
scenario) after symlinking. If hermes isn't resolvable, append an
idempotent PATH guard to /root/.bashrc and /root/.bash_profile, same
grep pattern already used by the ~/.local/bin branch below. No change
on distros where /usr/local/bin is already inherited.

* fix(update): repair RHEL root PATH on hermes update

Existing RHEL/CentOS/Rocky/Alma root installs won't be repaired by the
install.sh fix alone because 'hermes update' is an in-place git pull, not
a rerun of install.sh. Port the same probe + idempotent .bashrc write
into cmd_update so affected users get fixed automatically on next update.

_ensure_fhs_path_guard() runs after 'Update complete!':
- Linux + root + FHS-layout install (command at /usr/local/bin/hermes) only
- Probe: env -i bash -i -c 'command -v hermes' — fresh non-login interactive
  shell, same scenario the user reports
- On failure, append PATH guard to /root/.bashrc and /root/.bash_profile,
  skipping if any uncommented PATH line already mentions /usr/local/bin
- Silent no-op on macOS, non-root, legacy layout, or shells that already
  resolve hermes
2026-04-26 12:22:37 -07:00
Teknium 4b5a88d714 fix(slack): honor reply_in_thread=false for top-level channel messages
Top-level channel messages arrive at _resolve_thread_ts with
metadata.thread_id set to the message's own ts, because the inbound
handler in _handle_message_event uses 'event.ts' as a session-keying
fallback when event.thread_ts is absent. That made metadata alone
insufficient to distinguish a real thread reply from a top-level
message, so reply_in_thread=false only took effect in DMs.

Use reply_to (== incoming message_id == ts for top-level messages) as
the tiebreaker: when metadata.thread_id == reply_to the 'thread' is the
synthetic session-keying fallback, not a real parent, so we reply
directly in the channel. Real thread replies (reply_to != thread_id)
still resolve to the parent thread and preserve conversation context.

Closes #9268.
2026-04-26 12:04:46 -07:00
bde3249023 b1be86ef96 fix(gateway): bridge slack.reply_in_thread config 2026-04-26 12:04:46 -07:00
Teknium ae7687cdc5 chore(release): map zhiyanliu in AUTHOR_MAP 2026-04-26 11:56:23 -07:00
sgaofen c730f6cc0b test(gateway): cover Slack vs non-Slack home-channel onboarding hint
Parameterize the test helpers in test_status_command.py to accept a
Platform and add two regression tests ensuring the first-run home-channel
onboarding uses '/hermes sethome' on Slack and '/sethome' everywhere else.

Co-authored-by: sgaofen <135070653+sgaofen@users.noreply.github.com>
2026-04-26 11:56:23 -07:00
Zhi Yan Liu d993a3f450 fix(gateway): use /hermes sethome in onboarding hint on Slack
Slack's adapter registers a single parent slash command /hermes and
dispatches subcommands via slack_subcommand_map(). Bare /sethome is
not a registered command on Slack and fails with 'app did not
respond', logging 'Unhandled request' in slack_bolt.AsyncApp.

Show /hermes sethome in the first-run onboarding hint when the
source platform is Slack; keep /sethome for Telegram, Discord,
Matrix, Mattermost, and other platforms that register it directly.

Fixes #14632
2026-04-26 11:56:23 -07:00
Teknium 1dfcc2ffc3 fix(gateway): /queue is now a true FIFO — each invocation gets its own turn (#16175)
Repeated /queue commands now each produce a full agent turn, in order,
with no merging.  Previously the second /queue overwrote the first
because the handler wrote directly into the adapter's single-slot
_pending_messages dict.

- GatewayRunner grows a _queued_events overflow buffer (dict of list).
- /queue puts new items in the adapter's next-up slot when free,
  otherwise appends to the overflow.  After each run's drain consumes
  the slot, the next overflow item is promoted so the recursive run
  picks it up.
- /new and /reset clear the overflow.
- /status now reports queue depth when non-zero.
- Ack message shows the depth once it exceeds 1.

Helpers (_enqueue_fifo, _promote_queued_event, _queue_depth) use the
getattr default-fallback pattern so existing tests that build bare
GatewayRunner instances via object.__new__ keep working.
2026-04-26 11:55:09 -07:00
Teknium 5b2c59559a feat(terminal): collapse subagent task_ids to shared container (#16177)
Before: delegate_task children each allocated their own terminal
sandbox keyed by child task_id. Starting extra containers (or Modal
sandboxes / Daytona workspaces) is expensive, and the subagent's work
is invisible to the parent — files written by the child in its
container don't exist in the parent's when the subagent returns.

After: a single `_resolve_container_task_id` helper maps any
tool-call task_id to "default" UNLESS an env override is registered
for it. The parent agent and all delegate_task children therefore
share one long-lived sandbox — installed packages, cwd, /workspace
files, and /tmp scratch carry over freely between them.

RL and benchmark environments (TerminalBench2, HermesSweEnv, ...)
opt in to isolation via `register_task_env_overrides(task_id, {...})`;
those task_ids survive the collapse and get their own sandbox,
preserving the per-task Docker image behavior these benchmarks rely on.

file_state / active-subagents registry / TUI events still key off the
original child task_id, so the 'subagent wrote a file the parent read'
warning and UI per-subagent panels keep working.

Tradeoff: parallel delegate_task children (tasks=[...]) now share one
bash/container. Concurrent cd, env-var mutations, and writes to the
same path will collide. If that bites a specific workflow, the
subagent can opt back into isolation via register_task_env_overrides.

Applied at four lookup sites:
- tools/terminal_tool.py terminal_tool() and get_active_env()
- tools/file_tools.py _get_file_ops() and _get_live_tracking_cwd()
- tools/code_execution_tool.py _get_or_create_environment()

Docs: website/docs/user-guide/configuration.md updated to reflect the
shared-container reality and document the RL/benchmark carve-out.
Tests: tests/tools/test_shared_container_task_id.py (9 cases).
2026-04-26 11:55:02 -07:00
Teknium 087e74d4d7 feat(slack): register every gateway command as a native slash (Discord/Telegram parity) (#16164)
Every command in COMMAND_REGISTRY (/btw, /stop, /model, /help, /new,
/bg, /reset, ...) is now a first-class Slack slash command instead of
a /hermes <subcommand>. Users get the same autocomplete-driven slash
picker experience Slack users expect and that Discord and Telegram
already provide.

Previously Slack registered ONE native slash (/hermes) and split on
the first word, so typing /btw in Slack's composer got 'couldn't find
an app for /btw' because the workspace manifest never declared it.

Changes
- hermes_cli/commands.py: slack_native_slashes() + slack_app_manifest()
  generate a Slack manifest from the registry (canonical names +
  aliases + plugin commands), clamped to Slack's 50-slash cap with
  /hermes reserved as the catch-all.
- gateway/platforms/slack.py: single regex matcher dispatches every
  registered slash to _handle_slash_command, which dispatches on
  command['command']. Legacy /hermes <subcommand> keeps working for
  backward compat with older workspace manifests.
- hermes_cli/slack_cli.py + hermes_cli/main.py: new 'hermes slack
  manifest' command prints/writes a full manifest (display info,
  OAuth scopes, event subs, socket mode, slash commands) ready to
  paste into 'Create from manifest' or Features → App Manifest.
- hermes_cli/setup.py: _setup_slack() now writes the manifest up-front
  and points users at the 'From an app manifest' flow; also offers
  to refresh the manifest on reconfigure for picking up new commands.
- Tests: 14 new tests covering native-slash dispatch (/btw, /stop,
  /model), legacy /hermes <sub> compat, manifest structure, and
  telegram<->slack parity (every Telegram command must also register
  as a Slack slash). Existing /hermes-registration test updated to
  assert the new regex matches /hermes, /btw, /stop, /model, /help.
- Docs: slack.md gains a 'Slash Commands' section + Option A manifest
  flow in Step 1; cli-commands.md documents 'hermes slack manifest'.

Users pick up the new slashes by running 'hermes slack manifest --write'
and pasting into Features → App Manifest → Edit in their Slack app
config, then Save (Slack prompts for reinstall if scopes changed).
2026-04-26 11:38:32 -07:00
Teknium 9be83728a6 docs(docker-backend): clarify container is shared across sessions, not per-session (#16158)
The Docker terminal-backend docs said 'each session starts a long-lived
container', implying a fresh container per chat session. That hasn't been
true for a while: for the top-level agent, task_id defaults to 'default'
and the container is cached in _active_environments for the lifetime of
the Hermes process. /new, /reset, and switching sessions all reuse the
same container. Only delegate_task subagents and RL rollouts get isolated
containers keyed by their own task_id.
2026-04-26 10:46:08 -07:00
Teknium 9397767513 chore(skills): remove empty feeds category (#16153)
skills/feeds/ only contained a category-marker DESCRIPTION.md with no
actual skills in it. Removing the directory and the 'feeds' -> 'Feeds'
display-label mapping in website/scripts/extract-skills.py (the only
other reference in the repo).
2026-04-26 10:44:56 -07:00
Teknium 9662e3218a fix(tui): call maybe_auto_title for TUI sessions (#15949) (#16151)
* fix(tui): call maybe_auto_title for TUI sessions (#15961)

The maybe_auto_title() helper is called from cli.py and gateway/run.py
but was never wired into tui_gateway/server.py, so every session started
via 'hermes --tui' landed in state.db with an empty title. Evidence from
the issue reporter: 0/154 TUI sessions titled vs 91/383 CLI.

Mirror the CLI/Gateway pattern: after emitting message.complete, when the
turn finished cleanly, fire-and-forget title generation using the session
key, user prompt, agent response, and current history.

Fixes #15949.

Co-authored-by: math0r-be <math0r-be@github.com>

* chore(release): map math0r-be placeholder email in AUTHOR_MAP

---------

Co-authored-by: math0r-be <math0r-be@github.com>
2026-04-26 10:44:22 -07:00
Teknium 0824ba6a9d fix(/branch): redirect session_log_file and expose branch sessions in list (#14854) (#16150)
* fix(/branch): redirect session_log_file and expose branch sessions in list

Two bugs when using /branch:

1. cli.py _handle_branch_command updated agent.session_id but not
   agent.session_log_file, so all messages written after branching
   landed in the original session's JSON file and the branch never
   got its own session_{id}.json on disk.

   Fix: mirror the compression-split path (run_agent.py:7579) and
   update session_log_file immediately after changing session_id.

2. hermes_state.py list_sessions_rich filtered out every session
   with parent_session_id IS NOT NULL to hide sub-agent runs and
   compression continuations. Branch sessions share this column, so
   they became invisible to `hermes sessions list` and `sessions browse`.

   Fix: also include branch children — those whose parent ended with
   end_reason='branched' AND whose started_at >= parent.ended_at
   (the same timing condition that get_compression_tip uses to
   distinguish continuations from live-spawned subagents).

Fixes #14854

Co-Authored-By: Octopus <liyuan851277048@icloud.com>

* chore(release): map octo-patch placeholder email in AUTHOR_MAP

---------

Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Octopus <liyuan851277048@icloud.com>
2026-04-26 10:28:19 -07:00
Teknium 42c076d349 feat(browser): auto-spawn local Chromium for LAN/localhost URLs in cloud mode (#16136)
When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is
configured, browser_navigate now transparently spawns a local Chromium
sidecar for URLs whose host resolves to a private/loopback/LAN address
(localhost, 127.0.0.1, 192.168.x.x, 10.x.x.x, *.local, *.lan, *.internal,
::1, 169.254.x.x). Public URLs continue to use the cloud provider in the
same conversation.

Previously, setting BROWSERBASE_API_KEY / cloud_provider: browserbase
pinned the whole tool to cloud for the process — localhost URLs were
either SSRF-blocked (default) or sent to Browserbase (where they 404'd
because the cloud can't reach your LAN). Users who wanted 'cloud for
public, local for localhost' had no way to express it short of toggling
providers mid-session.

Implementation uses a composite session key scheme: the bare task_id
serves the cloud session, and a '{task_id}::local' sidecar serves the
local Chromium. _last_active_session_key[task_id] tracks which of the
two served the most recent nav so snapshot/click/fill/etc. hit the
correct one. cleanup_browser(bare_task_id) reaps both.

Feature is on by default. Opt out via:
  browser:
    auto_local_for_private_urls: false

The cloud provider never sees private URLs. Post-redirect SSRF guard
is preserved: redirects from public onto private addresses still block.
2026-04-26 09:57:58 -07:00
Teknium 0e2a53eab2 feat(skills): show enabled/disabled status in 'skills list' (#16129)
'hermes skills list' now shows every skill's enabled/disabled status
and accepts --enabled-only to filter down to what will actually load
for the active profile:

    hermes -p dario skills list --enabled-only

Previously the command was a flat catalog — it did not apply
skills.disabled from config.yaml, so there was no way to see the
live skill set for a profile without reading config by hand.
Profile switching already works via -p (swaps HERMES_HOME); this
just surfaces the result visibly.

Changes:
- hermes_cli/skills_hub.py: do_list adds a Status column and an
  enabled_only filter; summary reports enabled/disabled split
- hermes_cli/main.py: --enabled-only flag on 'skills list'
- /skills list slash command accepts --enabled-only too
- tests: 4 new (status column, disabled marking, enabled-only
  hiding, no platform leakage into get_disabled_skill_names);
  existing fixtures updated to accept skip_disabled kwarg

Reported by @mochizukimr on X.
2026-04-26 09:20:53 -07:00
Teknium eaa7e2db67 feat(cli,tui): surface /queue, /bg, /steer in agent-running placeholder (#16118)
* feat(cli,tui): surface /queue, /bg, /steer in agent-running placeholder

While the agent loop is running, the input placeholder previously only
hinted at Enter-to-interrupt. Surface the full set of busy-time actions
(interrupt via new message, /queue, /bg, /steer) so users discover them
without hunting through docs or Teknium's tweets.

- cli.py: "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
- ui-tui/src/components/appLayout.tsx: same string (was "Ctrl+C to interrupt…")

* revert tui placeholder change (cli-only per review)
2026-04-26 08:50:30 -07:00
briandevans 4e356098d2 fixup! fix(gateway): preserve inactivity clock on interrupt-recursive cached-agent turns (#15654)
Address Copilot review findings:

1. Gate _last_activity_desc on interrupt_depth == 0 alongside _last_activity_ts.
   Both fields are semantically paired — desc describes the activity *at* ts.
   Updating desc without ts made get_activity_summary() report "starting new
   turn (cached)" for 20+ minutes while the timestamp showed the true stale
   duration, producing misleading diagnostic output.

2. Monkeypatch gateway.run.time.time to a fixed epoch in tests that assert
   on _last_activity_ts values.  Real time.time() comparisons were latently
   flaky under slow CI or NTP adjustments.  _FAKE_NOW = 10_000.0 is used
   as the reference; assertions are now exact equality rather than >=.

3. Add test_fresh_turn_resets_desc and test_interrupt_turn_preserves_desc to
   directly cover the gated desc behaviour introduced by (1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:45:44 -07:00
briandevans de24315978 fix(gateway): preserve inactivity clock on interrupt-recursive cached-agent turns (#15654)
_last_activity_ts was unconditionally reset to time.time() on every
_agent_cache hit.  For interrupt-recursive _run_agent calls
(_interrupt_depth > 0) this silently reset the inactivity watchdog's
idle clock on each re-entry, preventing the 30-min timeout from ever
firing when a turn got stuck in an interrupt loop.  A stuck session
would emit "Still working... iteration 0/60, starting new turn (cached)"
heartbeats indefinitely instead of timing out.

Gate the reset on _interrupt_depth == 0 only.  Fresh external turns
still receive the reset so a session idle for 29 min doesn't trip the
watchdog before the new turn makes its first API call (#9051).

The per-turn reset logic is extracted into a static helper
_init_cached_agent_for_turn() to make it directly testable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:45:44 -07:00
Teknium 20cb706e03 chore: extend [SYSTEM:→[IMPORTANT: rename + AUTHOR_MAP
Follow-up to #6616 covering the remaining user-injected prompt markers that
the original PR did not touch (reporter's second comment on #6576 explicitly
flagged these). Azure OpenAI Default/DefaultV2 content filters treat any
bracketed [SYSTEM: ...] as prompt-injection and reject with HTTP 400.

Remaining call sites renamed:
- cli.py: background-process notifications (watch_disabled, watch_match,
  completion), MCP reload notice (4 live + 1 docstring)
- gateway/run.py: same notification paths + auto-loaded skill banner +
  MCP reload notice (5 live + 1 docstring)
- tools/process_registry.py: comment reference

Not renamed:
- environments/hermes_base_env.py '[SYSTEM]\n{content}' — RL training
  trajectory rendering only, never sent to Azure, part of a symmetric
  [USER]/[ASSISTANT]/[TOOL] scheme.

AUTHOR_MAP: buraysandro9@gmail.com -> ygd58.
2026-04-26 08:44:58 -07:00
ygd58 d7a3468246 fix(prompts): replace [SYSTEM: with [IMPORTANT: to avoid Azure content filter
Azure OpenAI content filters (Default/DefaultV2) treat bracketed
[SYSTEM: ...] meta-instructions as prompt-injection attempts and
reject requests with HTTP 400.

Replacing [SYSTEM: with [IMPORTANT: preserves the same semantic
meaning for the model while bypassing the Azure heuristic.

Fixes #6576
2026-04-26 08:44:58 -07:00
Teknium f2d655529a fix(auth): hoist get_env_value import + strengthen .env fallback tests
Follow-up to cherry-picked PR #15920:

- agent/credential_pool.py: hoist 'from hermes_cli.config import get_env_value'
  to module top instead of inline try/except in each seed site (3 sites).
  No import cycle — hermes_cli/config.py doesn't depend on agent.credential_pool.
- hermes_cli/auth.py: same hoist for the _resolve_api_key_provider_secret loop.
- tests/tools/test_credential_pool_env_fallback.py: replace smoke-only tests
  with real .env file I/O. Each test writes a temp ~/.hermes/.env, verifies
  _seed_from_env / _resolve_api_key_provider_secret read from it, and asserts
  the full priority chain: os.environ > .env > credential_pool. Uses
  'deepseek' as the test provider since 'openai' isn't in PROVIDER_REGISTRY
  and _seed_from_env's generic path requires a real pconfig lookup.
2026-04-26 08:32:09 -07:00
阿泥豆 27f4dba5ce test: add unit tests for credential pool env fallback 2026-04-26 08:32:09 -07:00
阿泥豆 8443998dc3 fix(auth): resolve API keys from ~/.hermes/.env and credential_pool
_resolve_api_key_provider_secret() and _seed_from_env() only checked
os.environ for provider API keys. When keys exist in ~/.hermes/.env but
are not loaded into the process environment (e.g. ACP adapter entry
point, post-session-start .env edits, or non-CLI entry points), the
resolution returns an empty string, causing HTTP 401 failures.

Changes:
- credential_pool._seed_from_env: use get_env_value() which checks both
  os.environ and ~/.hermes/.env file, preventing _prune_stale_seeded_entries
  from removing valid entries whose env var isn't in os.environ
- credential_pool._seed_from_env: same fix for openrouter and
  base_url_env_var resolution
- auth._resolve_api_key_provider_secret: use get_env_value() instead of
  os.getenv(), and add credential_pool fallback when env resolution fails

Fixes #15914
2026-04-26 08:32:09 -07:00
64 changed files with 4188 additions and 237 deletions
+6 -3
View File
@@ -14,6 +14,7 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import get_env_value
import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
@@ -1273,7 +1274,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
def _is_source_suppressed(_p, _s): # type: ignore[misc]
return False
if provider == "openrouter":
token = os.getenv("OPENROUTER_API_KEY", "").strip()
# Check both os.environ and ~/.hermes/.env file
token = (get_env_value("OPENROUTER_API_KEY") or "").strip()
if token:
source = "env:OPENROUTER_API_KEY"
if _is_source_suppressed(provider, source):
@@ -1299,7 +1301,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_url = ""
if pconfig.base_url_env_var:
env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")
env_vars = list(pconfig.api_key_env_vars)
if provider == "anthropic":
@@ -1310,7 +1312,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
]
for env_var in env_vars:
token = os.getenv(env_var, "").strip()
# Check both os.environ and ~/.hermes/.env file
token = (get_env_value(env_var) or "").strip()
if not token:
continue
source = f"env:{env_var}"
+2 -2
View File
@@ -329,7 +329,7 @@ def build_skill_invocation_message(
loaded_skill, skill_dir, skill_name = loaded
activation_note = (
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want '
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]"
)
return _build_skill_message(
@@ -368,7 +368,7 @@ def build_preloaded_skills_prompt(
loaded_skill, skill_dir, skill_name = loaded
activation_note = (
f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill '
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
"preloaded. Treat its instructions as active guidance for the duration of this "
"session unless the user overrides them.]"
)
+24 -6
View File
@@ -1378,7 +1378,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
def _format_process_notification(evt: dict) -> "str | None":
"""Format a process notification event into a [SYSTEM: ...] message.
"""Format a process notification event into a [IMPORTANT: ...] message.
Handles both completion events (notify_on_complete) and watch pattern
match events from the unified completion_queue.
@@ -1388,14 +1388,14 @@ def _format_process_notification(evt: dict) -> "str | None":
_cmd = evt.get("command", "unknown")
if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"
return f"[IMPORTANT: {evt.get('message', '')}]"
if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[SYSTEM: Background process {_sid} matched "
f"[IMPORTANT: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
@@ -1409,7 +1409,7 @@ def _format_process_notification(evt: dict) -> "str | None":
_exit = evt.get("exit_code", "?")
_out = evt.get("output", "")
return (
f"[SYSTEM: Background process {_sid} completed "
f"[IMPORTANT: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
@@ -4915,6 +4915,12 @@ class HermesCLI:
if self.agent:
self.agent.session_id = new_session_id
self.agent.session_start = now
# Redirect the JSON session log to the new branch session file so
# messages written after branching land in the correct file.
if hasattr(self.agent, "session_log_file") and hasattr(self.agent, "logs_dir"):
self.agent.session_log_file = (
self.agent.logs_dir / f"session_{new_session_id}.json"
)
self.agent.reset_session_state()
if hasattr(self.agent, "_last_flushed_db_idx"):
self.agent._last_flushed_db_idx = len(self.conversation_history)
@@ -6307,6 +6313,12 @@ class HermesCLI:
turn_route = self._resolve_turn_agent_config(prompt)
def run_background():
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
try:
set_secret_capture_callback(self._secret_capture_callback)
except Exception:
pass
try:
bg_agent = AIAgent(
model=turn_route["model"],
@@ -6404,6 +6416,12 @@ class HermesCLI:
print()
_cprint(f" ❌ Background task #{task_num} failed: {e}")
finally:
try:
set_sudo_password_callback(None)
set_approval_callback(None)
set_secret_capture_callback(None)
except Exception:
pass
self._background_tasks.pop(task_id, None)
# Clear spinner only if no foreground agent owns it
if not self._agent_running:
@@ -7217,7 +7235,7 @@ class HermesCLI:
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
self.conversation_history.append({
"role": "user",
"content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
})
# Persist session immediately so the session log reflects the
@@ -9841,7 +9859,7 @@ class HermesCLI:
status = cli_ref._command_status or "Processing command..."
return f"{frame} {status}"
if cli_ref._agent_running:
return "type a message + Enter to interrupt, Ctrl+C to cancel"
return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
if cli_ref._voice_mode:
return "type or Ctrl+B to record"
return ""
+3 -3
View File
@@ -715,7 +715,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
cron_hint = (
"[SYSTEM: You are running as a scheduled cron job. "
"[IMPORTANT: You are running as a scheduled cron job. "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
@@ -751,7 +751,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
parts.append("")
parts.extend(
[
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
"",
content,
]
@@ -759,7 +759,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
if skipped:
notice = (
f"[SYSTEM: The following skill(s) were listed for this job but could not be found "
f"[IMPORTANT: The following skill(s) were listed for this job but could not be found "
f"and were skipped: {', '.join(skipped)}. "
f"Start your response with a brief notice so the user is aware, e.g.: "
f"'⚠️ Skill(s) not found and skipped: {', '.join(skipped)}']"
+67 -14
View File
@@ -57,7 +57,7 @@ def _session_entry_name(origin: Dict[str, Any]) -> str:
# Build / refresh
# ---------------------------------------------------------------------------
def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
"""
Build a channel directory from connected platform adapters and session data.
@@ -72,7 +72,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
if platform == Platform.DISCORD:
platforms["discord"] = _build_discord(adapter)
elif platform == Platform.SLACK:
platforms["slack"] = _build_slack(adapter)
platforms["slack"] = await _build_slack(adapter)
except Exception as e:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
@@ -136,21 +136,66 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
return channels
def _build_slack(adapter) -> List[Dict[str, str]]:
"""List Slack channels the bot has joined."""
# Slack adapter may expose a web client
client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None)
if not client:
async def _build_slack(adapter) -> List[Dict[str, Any]]:
"""List Slack channels the bot has joined across all workspaces.
Uses ``users.conversations`` against each workspace's web client. Pulls
public + private channels the bot is a member of, then merges in DMs
discovered from session history (IMs aren't useful to enumerate
proactively).
"""
team_clients = getattr(adapter, "_team_clients", None) or {}
if not team_clients:
return _build_from_sessions("slack")
try:
from tools.send_message_tool import _send_slack # noqa: F401
# Use the Slack Web API directly if available
except Exception:
pass
channels: List[Dict[str, Any]] = []
seen_ids: set = set()
# Fallback to session data
return _build_from_sessions("slack")
for team_id, client in team_clients.items():
try:
cursor: Optional[str] = None
for _page in range(20): # safety cap on pagination
response = await client.users_conversations(
types="public_channel,private_channel",
exclude_archived=True,
limit=200,
cursor=cursor,
)
if not response.get("ok"):
logger.warning(
"Channel directory: users.conversations not ok for team %s: %s",
team_id,
response.get("error", "unknown"),
)
break
for ch in response.get("channels", []):
cid = ch.get("id")
name = ch.get("name")
if not cid or not name or cid in seen_ids:
continue
seen_ids.add(cid)
channels.append({
"id": cid,
"name": name,
"type": "private" if ch.get("is_private") else "channel",
})
cursor = (response.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
except Exception as e:
logger.warning(
"Channel directory: failed to list Slack channels for team %s: %s",
team_id, e,
)
continue
# Merge in DM/group entries discovered from session history.
for entry in _build_from_sessions("slack"):
if entry.get("id") not in seen_ids:
channels.append(entry)
seen_ids.add(entry.get("id"))
return channels
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
@@ -223,6 +268,14 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
if not channels:
return None
# 0. Exact ID match — case-sensitive, no normalization. Lets callers pass
# raw platform IDs (e.g. Slack "C0B0QV5434G") even when the format guard
# in _parse_target_ref hasn't recognized them as explicit.
raw = name.strip()
for ch in channels:
if ch.get("id") == raw:
return ch["id"]
query = _normalize_channel_query(name)
# 1. Exact name match, including the display labels shown by send_message(action="list")
+4
View File
@@ -570,6 +570,8 @@ def load_gateway_config() -> GatewayConfig:
)
if "reply_prefix" in platform_cfg:
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
if "reply_in_thread" in platform_cfg:
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
if "require_mention" in platform_cfg:
bridged["require_mention"] = platform_cfg["require_mention"]
if "free_response_channels" in platform_cfg:
@@ -609,6 +611,8 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(slack_cfg, dict):
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
frc = slack_cfg.get("free_response_channels")
+295 -29
View File
@@ -15,7 +15,7 @@ import os
import re
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Any, Tuple
from typing import Dict, Optional, Any, Tuple, List
try:
from slack_bolt.async_app import AsyncApp
@@ -55,6 +55,7 @@ class _ThreadContextCache:
content: str
fetched_at: float = field(default_factory=time.monotonic)
message_count: int = 0
parent_text: str = "" # Raw text of the thread parent (for reply_to_text injection)
def check_slack_requirements() -> bool:
@@ -120,6 +121,63 @@ class SlackAdapter(BasePlatformAdapter):
# clear them (chat_id → thread_ts).
self._active_status_threads: Dict[str, str] = {}
def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Convert Slack API auth/permission failures into actionable user-facing text."""
if response is None or not hasattr(response, "get"):
return None
error = str(response.get("error", "") or "").strip()
if not error:
return None
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
needed = str(response.get("needed", "") or "").strip()
provided = str(response.get("provided", "") or "").strip()
reinstall_hint = " Update the Slack app scopes/settings and reinstall the app to the workspace."
provided_hint = f" Current bot scopes: {provided}." if provided else ""
if error == "missing_scope":
needed_hint = f"Missing scope: {needed}." if needed else "Missing required Slack scope."
return f"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}"
if error in {"not_authed", "invalid_auth", "account_inactive", "token_revoked"}:
return f"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app."
if error in {"file_not_found", "file_deleted"}:
return f"Slack attachment {file_label} is no longer available ({error})."
if error in {"access_denied", "file_access_denied", "no_permission", "not_allowed_token_type", "restricted_action"}:
return f"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed."
return None
def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Translate Slack download exceptions into user-facing attachment diagnostics."""
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
response = getattr(exc, "response", None)
api_detail = self._describe_slack_api_error(response, file_obj=file_obj)
if api_detail:
return api_detail
try:
import httpx
except Exception: # pragma: no cover
httpx = None
if httpx is not None and isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
if status == 401:
return f"Slack attachment access failed for {file_label} with HTTP 401. The bot token is not authorized for this file."
if status == 403:
return f"Slack attachment access failed for {file_label} with HTTP 403. The bot likely lacks permission or scope to read this file."
if status == 404:
return f"Slack attachment {file_label} returned HTTP 404 and is no longer reachable."
message = str(exc)
if "Slack returned HTML instead of media" in message or "non-image data" in message:
return (
f"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. "
"This usually means a scope, auth, or file-permission problem."
)
return None
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
if not SLACK_AVAILABLE:
@@ -207,8 +265,31 @@ class SlackAdapter(BasePlatformAdapter):
async def handle_assistant_thread_context_changed(event, say):
await self._handle_assistant_thread_lifecycle_event(event)
# Register slash command handler
@self._app.command("/hermes")
# Register slash command handler(s)
#
# Every gateway command from COMMAND_REGISTRY is a native Slack
# slash, matching Discord and Telegram's model (e.g. /btw, /stop,
# /model work directly without /hermes prefix). A single regex
# matcher dispatches all of them to one handler so we don't need
# N identical @app.command() decorators.
#
# The slash commands must ALSO be declared in the Slack app
# manifest (see `hermes slack manifest`). In Socket Mode, Slack
# routes the command event through the socket regardless of the
# manifest's request URL, but it will not deliver an event for
# a slash command the manifest doesn't declare.
from hermes_cli.commands import slack_native_slashes
import re as _re
_slash_names = [name for name, _d, _h in slack_native_slashes()]
if _slash_names:
_slash_pattern = _re.compile(
r"^/(?:" + "|".join(_re.escape(n) for n in _slash_names) + r")$"
)
else: # pragma: no cover - registry always non-empty
_slash_pattern = _re.compile(r"^/hermes$")
@self._app.command(_slash_pattern)
async def handle_hermes_command(ack, command):
await ack()
await self._handle_slash_command(command)
@@ -427,8 +508,18 @@ class SlackAdapter(BasePlatformAdapter):
"""
# When reply_in_thread is disabled (default: True for backward compat),
# only thread messages that are already part of an existing thread.
# For top-level channel messages, the inbound handler sets
# metadata.thread_id to the message's own ts as a session-keying
# fallback (see the `thread_ts = event.get("thread_ts") or ts` branch),
# so metadata alone can't distinguish a real thread reply from a
# top-level message. reply_to is the incoming message's own id, so
# when thread_id == reply_to the "thread" is synthetic and we reply
# directly in the channel instead.
if not self.config.extra.get("reply_in_thread", True):
existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts")
md = metadata or {}
existing_thread = md.get("thread_id") or md.get("thread_ts")
if existing_thread and reply_to and existing_thread == reply_to:
existing_thread = None
return existing_thread or None
if metadata:
@@ -1100,6 +1191,8 @@ class SlackAdapter(BasePlatformAdapter):
pass # Free-response channel — always process
elif not self._slack_require_mention():
pass # Mention requirement disabled globally for Slack
elif self._slack_strict_mention() and not is_mentioned:
return # Strict mode: ignore until @-mentioned again
elif not is_mentioned:
reply_to_bot_thread = (
is_thread_reply and event_thread_ts in self._bot_message_ts
@@ -1122,8 +1215,11 @@ class SlackAdapter(BasePlatformAdapter):
if is_mentioned:
# Strip the bot mention from the text
text = text.replace(f"<@{bot_uid}>", "").strip()
# Register this thread so all future messages auto-trigger the bot
if event_thread_ts:
# Register this thread so all future messages auto-trigger the bot.
# Skipped in strict mode: strict_mention=true bots must be
# re-mentioned every turn, so remembering the thread would
# defeat the feature (and re-enable agent-to-agent ack loops).
if event_thread_ts and not self._slack_strict_mention():
self._mentioned_threads.add(event_thread_ts)
if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX:
to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2]
@@ -1154,8 +1250,43 @@ class SlackAdapter(BasePlatformAdapter):
# Handle file attachments
media_urls = []
media_types = []
attachment_notices: List[str] = []
files = event.get("files", [])
for f in files:
# Slack Connect channels return stub file objects with
# file_access="check_file_info" and no URL fields. We must
# call files.info to retrieve the full object (including url_private_download)
# before we can download it.
# https://docs.slack.dev/reference/objects/file-object/#slack_connect_files
if f.get("file_access") == "check_file_info":
file_id = f.get("id")
if not file_id:
continue
try:
info_resp = await self._get_client(channel_id).files_info(file=file_id)
if info_resp.get("ok"):
f = info_resp["file"]
else:
detail = self._describe_slack_api_error(info_resp, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning(
"[Slack] files.info failed for %s: %s",
file_id, info_resp.get("error"),
)
continue
except Exception as e:
response = getattr(e, "response", None)
detail = self._describe_slack_api_error(response, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True)
continue
mimetype = f.get("mimetype", "unknown")
url = f.get("url_private_download") or f.get("url_private", "")
if mimetype.startswith("image/") and url:
@@ -1169,7 +1300,12 @@ class SlackAdapter(BasePlatformAdapter):
media_types.append(mimetype)
msg_type = MessageType.PHOTO
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
elif mimetype.startswith("audio/") and url:
try:
ext = "." + mimetype.split("/")[-1].split(";")[0]
@@ -1180,7 +1316,12 @@ class SlackAdapter(BasePlatformAdapter):
media_types.append(mimetype)
msg_type = MessageType.VOICE
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
elif url:
# Try to handle as a document attachment
try:
@@ -1232,7 +1373,16 @@ class SlackAdapter(BasePlatformAdapter):
pass # Binary content, skip injection
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
if attachment_notices:
notice_block = "[Slack attachment notice]\n" + "\n".join(f"- {n}" for n in attachment_notices)
text = f"{notice_block}\n\n{text}" if text else notice_block
# Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
@@ -1253,6 +1403,22 @@ class SlackAdapter(BasePlatformAdapter):
self.config.extra, channel_id, None,
)
# Extract reply context if this message is a thread reply.
# Mirrors the Telegram/Discord implementations so that gateway.run
# can inject a `[Replying to: "..."]` prefix when the parent is not
# already in the session history. Uses the thread-context cache when
# available to avoid redundant conversations.replies calls.
reply_to_text = None
if thread_ts and thread_ts != ts:
try:
reply_to_text = await self._fetch_thread_parent_text(
channel_id=channel_id,
thread_ts=thread_ts,
team_id=team_id,
) or None
except Exception: # pragma: no cover - defensive
reply_to_text = None
msg_event = MessageEvent(
text=text,
message_type=msg_type,
@@ -1263,6 +1429,7 @@ class SlackAdapter(BasePlatformAdapter):
media_types=media_types,
reply_to_message_id=thread_ts if thread_ts != ts else None,
channel_prompt=_channel_prompt,
reply_to_text=reply_to_text,
)
# Only react when bot is directly addressed (DM or @mention).
@@ -1470,7 +1637,7 @@ class SlackAdapter(BasePlatformAdapter):
Returns a formatted string with prior thread history, or empty string
on failure or if the thread has no prior messages.
"""
cache_key = f"{channel_id}:{thread_ts}"
cache_key = f"{channel_id}:{thread_ts}:{team_id}"
now = time.monotonic()
cached = self._thread_context_cache.get(cache_key)
if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
@@ -1517,14 +1684,37 @@ class SlackAdapter(BasePlatformAdapter):
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
context_parts = []
parent_text = ""
for msg in messages:
msg_ts = msg.get("ts", "")
# Exclude the current triggering message — it will be delivered
# as the user message itself, so including it here would duplicate it.
if msg_ts == current_ts:
continue
# Exclude our own bot messages to avoid circular context.
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
is_parent = msg_ts == thread_ts
is_bot = bool(msg.get("bot_id")) or msg.get("subtype") == "bot_message"
msg_user = msg.get("user", "")
# Identify "our own" bot for this workspace (multi-workspace safe).
msg_team = msg.get("team") or team_id
self_bot_uid = (
self._team_bot_user_ids.get(msg_team)
if msg_team
else None
) or self._bot_user_id
# Exclude only our own prior bot replies (circular context).
# Keep:
# - the thread parent even if it was posted by a bot
# (e.g. a cron job summary we are now replying to);
# - other bots' child messages (useful third-party context).
if (
is_bot
and not is_parent
and self_bot_uid
and msg_user == self_bot_uid
):
continue
msg_text = msg.get("text", "").strip()
@@ -1535,11 +1725,15 @@ class SlackAdapter(BasePlatformAdapter):
if bot_uid:
msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
msg_user = msg.get("user", "unknown")
is_parent = msg_ts == thread_ts
prefix = "[thread parent] " if is_parent else ""
name = await self._resolve_user_name(msg_user, chat_id=channel_id)
display_user = msg_user or "unknown"
# Prefer the bot's own name when the message is a bot post.
if is_bot and not display_user:
display_user = msg.get("username") or "bot"
name = await self._resolve_user_name(display_user, chat_id=channel_id)
context_parts.append(f"{prefix}{name}: {msg_text}")
if is_parent:
parent_text = msg_text
content = ""
if context_parts:
@@ -1553,6 +1747,7 @@ class SlackAdapter(BasePlatformAdapter):
content=content,
fetched_at=now,
message_count=len(context_parts),
parent_text=parent_text,
)
return content
@@ -1560,8 +1755,62 @@ class SlackAdapter(BasePlatformAdapter):
logger.warning("[Slack] Failed to fetch thread context: %s", e)
return ""
async def _fetch_thread_parent_text(
self, channel_id: str, thread_ts: str, team_id: str = "",
) -> str:
"""Return the raw text of the thread parent message (for reply_to_text).
Uses the same per-thread cache as :meth:`_fetch_thread_context` to avoid
hitting ``conversations.replies`` twice. Falls back to a cheap single-
message fetch (``limit=1, inclusive=True``) when the cache is cold.
Returns empty string on any failure — callers should treat an empty
return as "no parent context to inject".
"""
cache_key = f"{channel_id}:{thread_ts}:{team_id}"
now = time.monotonic()
cached = self._thread_context_cache.get(cache_key)
if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
return cached.parent_text
try:
client = self._get_client(channel_id)
result = await client.conversations_replies(
channel=channel_id,
ts=thread_ts,
limit=1,
inclusive=True,
)
messages = result.get("messages", []) if result else []
if not messages:
return ""
parent = messages[0]
if parent.get("ts", "") != thread_ts:
return ""
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
text = (parent.get("text") or "").strip()
if bot_uid:
text = text.replace(f"<@{bot_uid}>", "").strip()
return text
except Exception as exc: # pragma: no cover - defensive
logger.debug("[Slack] Failed to fetch thread parent text: %s", exc)
return ""
async def _handle_slash_command(self, command: dict) -> None:
"""Handle /hermes slash command."""
"""Handle Slack slash commands.
Every gateway command in COMMAND_REGISTRY is registered as a native
Slack slash (``/btw``, ``/stop``, ``/model``, etc.), matching the
Discord and Telegram model. The slash name itself is the command;
any text after it is the argument list.
The legacy ``/hermes <subcommand> [args]`` form is preserved for
backward compatibility with older workspace manifests and for users
who want a single entry point for free-form questions (``/hermes
what's the weather`` — non-slash text is treated as a regular
message).
"""
slash_name = (command.get("command") or "").lstrip("/").strip()
text = command.get("text", "").strip()
user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "")
@@ -1571,20 +1820,25 @@ class SlackAdapter(BasePlatformAdapter):
if team_id and channel_id:
self._channel_team[channel_id] = team_id
# Map subcommands to gateway commands — derived from central registry.
# Also keep "compact" as a Slack-specific alias for /compress.
from hermes_cli.commands import slack_subcommand_map
subcommand_map = slack_subcommand_map()
subcommand_map["compact"] = "/compress"
first_word = text.split()[0] if text else ""
if first_word in subcommand_map:
# Preserve arguments after the subcommand
rest = text[len(first_word):].strip()
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
elif text:
pass # Treat as a regular question
if slash_name in ("hermes", ""):
# Legacy /hermes <subcommand> [args] routing + free-form questions.
# Empty slash_name falls into this branch for backward compat
# with any caller that didn't populate command["command"].
from hermes_cli.commands import slack_subcommand_map
subcommand_map = slack_subcommand_map()
subcommand_map["compact"] = "/compress"
first_word = text.split()[0] if text else ""
if first_word in subcommand_map:
rest = text[len(first_word):].strip()
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
elif text:
pass # Treat as a regular question
else:
text = "/help"
else:
text = "/help"
# Native slash — /<slash_name> [args]. Route directly through the
# gateway command dispatcher by prepending the slash.
text = f"/{slash_name} {text}".strip()
source = self.build_source(
chat_id=channel_id,
@@ -1732,6 +1986,18 @@ class SlackAdapter(BasePlatformAdapter):
return bool(configured)
return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
def _slack_strict_mention(self) -> bool:
"""When true, channel threads require an explicit @-mention on every
message. Disables all auto-triggers (mentioned-thread memory,
bot-message follow-up, session-presence). Defaults to False.
"""
configured = self.config.extra.get("strict_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in ("true", "1", "yes", "on")
return bool(configured)
return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on")
def _slack_free_response_channels(self) -> set:
"""Return channel IDs where no @mention is required."""
raw = self.config.extra.get("free_response_channels")
+157 -19
View File
@@ -591,20 +591,20 @@ def _parse_session_key(session_key: str) -> "dict | None":
def _format_gateway_process_notification(evt: dict) -> "str | None":
"""Format a watch pattern event from completion_queue into a [SYSTEM:] message."""
"""Format a watch pattern event from completion_queue into a [IMPORTANT:] message."""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")
if evt_type == "watch_disabled":
return f"[SYSTEM: {evt.get('message', '')}]"
return f"[IMPORTANT: {evt.get('message', '')}]"
if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[SYSTEM: Background process {_sid} matched "
f"[IMPORTANT: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
@@ -682,6 +682,16 @@ class GatewayRunner:
self._running_agents: Dict[str, Any] = {}
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
# Overflow buffer for explicit /queue commands. The adapter-level
# _pending_messages dict is a single slot per session (designed for
# "next-turn" follow-ups where repeated sends collapse into one
# event). /queue has different semantics: each invocation must
# produce its own full agent turn, in FIFO order, with no merging.
# When the slot is occupied, additional /queue items land here and
# are promoted one-at-a-time after each run's drain. Cleared on
# /new and /reset. /model and other mid-session operations
# preserve the queue.
self._queued_events: Dict[str, List[MessageEvent]] = {}
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
self._session_run_generation: Dict[str, int] = {}
@@ -1204,6 +1214,76 @@ class GatewayRunner:
def _queue_during_drain_enabled(self) -> bool:
return self._restart_requested and self._busy_input_mode == "queue"
# -------- /queue FIFO helpers --------------------------------------
# /queue must produce one full agent turn per invocation, in FIFO
# order, with no merging. The adapter's _pending_messages dict is a
# single "next-up" slot (shared with photo-burst follow-ups), so we
# use it for the head of the queue and an overflow list for the
# tail. Enqueue puts new items in the slot when free, otherwise in
# the overflow. Promotion (called after each run's drain) moves the
# next overflow item into the slot so the following recursion picks
# it up. Clearing happens on /new and /reset via
# _handle_reset_command.
def _enqueue_fifo(self, session_key: str, queued_event: "MessageEvent", adapter: Any) -> None:
"""Append a /queue event to the FIFO chain for a session."""
if adapter is None:
return
pending_slot = getattr(adapter, "_pending_messages", None)
if pending_slot is None:
return
queued_events = getattr(self, "_queued_events", None)
if queued_events is None:
queued_events = {}
self._queued_events = queued_events
if session_key in pending_slot:
queued_events.setdefault(session_key, []).append(queued_event)
else:
pending_slot[session_key] = queued_event
def _promote_queued_event(
self,
session_key: str,
adapter: Any,
pending_event: Optional["MessageEvent"],
) -> Optional["MessageEvent"]:
"""Promote the next overflow item after the slot was drained.
Called at the drain site after _dequeue_pending_event consumed
(or failed to consume) the slot. If there's an overflow item:
- When pending_event is None (slot was empty), return the
overflow head as the new pending_event.
- When pending_event already exists (slot was populated by an
interrupt follow-up or similar), stage the overflow head in
the slot so the NEXT recursion picks it up.
Returns the (possibly updated) pending_event for drain to use.
"""
queued_events = getattr(self, "_queued_events", None)
if not queued_events:
return pending_event
overflow = queued_events.get(session_key)
if not overflow:
return pending_event
next_queued = overflow.pop(0)
if not overflow:
queued_events.pop(session_key, None)
if pending_event is None:
return next_queued
if adapter is not None and hasattr(adapter, "_pending_messages"):
adapter._pending_messages[session_key] = next_queued
else:
# No adapter — push back so we don't silently drop the item.
queued_events.setdefault(session_key, []).insert(0, next_queued)
return pending_event
def _queue_depth(self, session_key: str, *, adapter: Any = None) -> int:
"""Total pending /queue items for a session — slot + overflow."""
queued_events = getattr(self, "_queued_events", None) or {}
depth = len(queued_events.get(session_key, []))
if adapter is not None and session_key in getattr(adapter, "_pending_messages", {}):
depth += 1
return depth
def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:
try:
from gateway.status import write_runtime_status
@@ -2254,7 +2334,7 @@ class GatewayRunner:
# Build initial channel directory for send_message name resolution
try:
from gateway.channel_directory import build_channel_directory
directory = build_channel_directory(self.adapters)
directory = await build_channel_directory(self.adapters)
ch_count = sum(len(chs) for chs in directory.get("platforms", {}).values())
logger.info("Channel directory built: %d target(s)", ch_count)
except Exception as e:
@@ -2538,7 +2618,7 @@ class GatewayRunner:
# Rebuild channel directory with the new adapter
try:
from gateway.channel_directory import build_channel_directory
build_channel_directory(self.adapters)
await build_channel_directory(self.adapters)
except Exception:
pass
else:
@@ -3416,7 +3496,10 @@ class GatewayRunner:
# doesn't think an agent is still active.
return await self._handle_reset_command(event)
# /queue <prompt> — queue without interrupting
# /queue <prompt> — queue without interrupting.
# Semantics: each /queue invocation produces its own full agent
# turn, processed in FIFO order after the current run (and any
# earlier /queue items) finishes. Messages are NOT merged.
if event.get_command() in ("queue", "q"):
queued_text = event.get_command_args().strip()
if not queued_text:
@@ -3430,8 +3513,11 @@ class GatewayRunner:
message_id=event.message_id,
channel_prompt=event.channel_prompt,
)
adapter._pending_messages[_quick_key] = queued_event
return "Queued for the next turn."
self._enqueue_fifo(_quick_key, queued_event, adapter)
depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform))
if depth <= 1:
return "Queued for the next turn."
return f"Queued for the next turn. ({depth} queued)"
# /steer <prompt> — inject mid-run after the next tool call.
# Unlike /queue (turn boundary), /steer lands BETWEEN tool-call
@@ -4232,7 +4318,7 @@ class GatewayRunner:
if _loaded:
_loaded_skill, _skill_dir, _display_name = _loaded
_note = (
f'[SYSTEM: The "{_display_name}" skill is auto-loaded. '
f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. '
f"Follow its instructions for this session.]"
)
_part = _build_skill_message(_loaded_skill, _skill_dir, _note)
@@ -4520,12 +4606,20 @@ class GatewayRunner:
if not os.getenv(env_key):
adapter = self.adapters.get(source.platform)
if adapter:
# Slack dispatches all Hermes commands through a single
# parent slash command `/hermes`; bare `/sethome` is not
# registered and would fail with "app did not respond".
sethome_cmd = (
"/hermes sethome"
if source.platform == Platform.SLACK
else "/sethome"
)
await adapter.send(
source.chat_id,
f"📬 No home channel is set for {platform_name.title()}. "
f"A home channel is where Hermes delivers cron job results "
f"and cross-platform messages.\n\n"
f"Type /sethome to make this chat your home channel, "
f"Type {sethome_cmd} to make this chat your home channel, "
f"or ignore to skip."
)
@@ -5058,6 +5152,13 @@ class GatewayRunner:
self._cleanup_agent_resources(_old_agent)
self._evict_cached_agent(session_key)
# Discard any /queue overflow for this session — /new is a
# conversation-boundary operation, queued follow-ups from the
# previous conversation must not bleed into the new one.
_qe = getattr(self, "_queued_events", None)
if _qe is not None:
_qe.pop(session_key, None)
try:
from tools.env_passthrough import clear_env_passthrough
clear_env_passthrough()
@@ -5165,6 +5266,10 @@ class GatewayRunner:
session_key = session_entry.session_key
is_running = session_key in self._running_agents
# Count pending /queue follow-ups (slot + overflow).
adapter = self.adapters.get(source.platform) if source else None
queue_depth = self._queue_depth(session_key, adapter=adapter)
title = None
if self._session_db:
try:
@@ -5184,6 +5289,10 @@ class GatewayRunner:
f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}",
f"**Tokens:** {session_entry.total_tokens:,}",
f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}",
])
if queue_depth:
lines.append(f"**Queued follow-ups:** {queue_depth}")
lines.extend([
"",
f"**Connected Platforms:** {', '.join(connected_platforms)}",
])
@@ -7473,7 +7582,7 @@ class GatewayRunner:
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
reload_msg = {
"role": "user",
"content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
}
try:
session_entry = self.session_store.get_or_create_session(event.source)
@@ -8412,7 +8521,7 @@ class GatewayRunner:
from tools.ansi_strip import strip_ansi
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
synth_text = (
f"[SYSTEM: Background process {session_id} completed "
f"[IMPORTANT: Background process {session_id} completed "
f"(exit code {session.exit_code}).\n"
f"Command: {session.command}\n"
f"Output:\n{_out}]"
@@ -8722,6 +8831,25 @@ class GatewayRunner:
with _lock:
self._agent_cache.pop(session_key, None)
@staticmethod
def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None:
"""Reset per-turn state on a cached agent before a new turn starts.
Both _last_activity_ts and _last_activity_desc are only reset for
fresh external turns (depth 0); they are semantically paired
desc describes the activity *at* ts, so updating one without the
other would make get_activity_summary() misleading.
For interrupt-recursive turns both are preserved so the inactivity
watchdog can accumulate stuck-turn idle time and fire the 30-min
timeout (#15654). The depth-0 reset is still needed: a session
idle for 29 min would otherwise trip the watchdog before the new
turn makes its first API call (#9051).
"""
if interrupt_depth == 0:
agent._last_activity_ts = time.time()
agent._last_activity_desc = "starting new turn (cached)"
agent._api_call_count = 0
def _release_evicted_agent_soft(self, agent: Any) -> None:
"""Soft cleanup for cache-evicted agents — preserves session tool state.
@@ -9766,12 +9894,7 @@ class GatewayRunner:
_cache.move_to_end(session_key)
except KeyError:
pass
# Reset activity timestamp so the inactivity timeout
# handler doesn't see stale idle time from the previous
# turn and immediately kill this agent. (#9051)
agent._last_activity_ts = time.time()
agent._last_activity_desc = "starting new turn (cached)"
agent._api_call_count = 0
self._init_cached_agent_for_turn(agent, _interrupt_depth)
logger.debug("Reusing cached agent for session %s", session_key)
if agent is None:
@@ -10554,6 +10677,13 @@ class GatewayRunner:
pending = None
if result and adapter and session_key:
pending_event = _dequeue_pending_event(adapter, session_key)
# /queue overflow: after consuming the adapter's "next-up"
# slot, promote the next queued event into it so the
# recursive run's drain will see it. This keeps the slot
# occupied for the full FIFO chain, which (a) preserves
# order, and (b) causes any mid-chain /queue to correctly
# route to overflow rather than jumping the queue.
pending_event = self._promote_queued_event(session_key, adapter, pending_event)
if result.get("interrupted") and not pending_event and result.get("interrupt_message"):
interrupt_message = result.get("interrupt_message")
if _is_control_interrupt_message(interrupt_message):
@@ -10848,7 +10978,15 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
if tick_count % CHANNEL_DIR_EVERY == 0 and adapters:
try:
from gateway.channel_directory import build_channel_directory
build_channel_directory(adapters)
if loop is not None:
# build_channel_directory is async (Slack web calls), and
# this ticker runs in a background thread. Schedule onto
# the gateway event loop and wait briefly for completion
# so refresh failures are still logged via the except.
fut = asyncio.run_coroutine_threadsafe(
build_channel_directory(adapters), loop
)
fut.result(timeout=30)
except Exception as e:
logger.debug("Channel directory refresh error: %s", e)
+17 -1
View File
@@ -467,11 +467,27 @@ def _resolve_api_key_provider_secret(
pass
return "", ""
from hermes_cli.config import get_env_value
for env_var in pconfig.api_key_env_vars:
val = os.getenv(env_var, "").strip()
# Check both os.environ and ~/.hermes/.env file
val = (get_env_value(env_var) or "").strip()
if has_usable_secret(val):
return val, env_var
# Fallback: try credential pool (e.g. zai key stored via auth.json)
try:
from agent.credential_pool import load_pool
pool = load_pool(provider_id)
if pool and pool.has_credentials():
entry = pool.peek()
if entry:
key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
key = str(key).strip()
if has_usable_secret(key):
return key, f"credential_pool:{provider_id}"
except Exception:
pass
return "", ""
+108
View File
@@ -806,6 +806,114 @@ def discord_skill_commands_by_category(
return trimmed_categories, uncategorized, hidden
# ---------------------------------------------------------------------------
# Slack native slash commands
# ---------------------------------------------------------------------------
# Slack slash command name constraints: lowercase a-z, 0-9, hyphens,
# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash
# commands per app.
_SLACK_MAX_SLASH_COMMANDS = 50
_SLACK_NAME_LIMIT = 32
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
def _sanitize_slack_name(raw: str) -> str:
"""Convert a command name to a valid Slack slash command name.
Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32
chars. Uppercase is lowercased; invalid chars are stripped.
"""
name = raw.lower()
name = _SLACK_INVALID_CHARS.sub("", name)
name = name.strip("-_")
return name[:_SLACK_NAME_LIMIT]
def slack_native_slashes() -> list[tuple[str, str, str]]:
"""Return (slash_name, description, usage_hint) triples for Slack.
Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as
a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``),
matching Discord's and Telegram's model where every command is a
first-class slash and not a ``/hermes <verb>`` subcommand.
Both canonical names and aliases are included so users can type any
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
Plugin-registered slash commands are included too.
Results are clamped to Slack's 50-command limit with duplicate-name
avoidance. ``/hermes`` is always reserved as the first entry so the
legacy ``/hermes <subcommand>`` form keeps working for anything that
gets dropped by the clamp or for free-form questions.
"""
overrides = _resolve_config_gates()
entries: list[tuple[str, str, str]] = []
seen: set[str] = set()
# Reserve /hermes as the catch-all top-level command.
entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]"))
seen.add("hermes")
def _add(name: str, desc: str, hint: str) -> None:
slack_name = _sanitize_slack_name(name)
if not slack_name or slack_name in seen:
return
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
return
# Slack description cap is 2000 chars; keep it short.
entries.append((slack_name, desc[:140], hint[:100]))
seen.add(slack_name)
# First pass: canonical names (so they win slots if we hit the cap).
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
_add(cmd.name, cmd.description, cmd.args_hint or "")
# Second pass: aliases.
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
for alias in cmd.aliases:
# Skip aliases that only differ from canonical by case/punctuation
# normalization (already covered by _add dedup).
_add(alias, f"Alias for /{cmd.name}{cmd.description}", cmd.args_hint or "")
# Third pass: plugin commands.
for name, description, args_hint in _iter_plugin_command_entries():
_add(name, description, args_hint or "")
return entries
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
"""Generate a Slack app manifest with all gateway commands as slashes.
``request_url`` is required by Slack's manifest schema for every slash
command, but in Socket Mode (which we use) Slack ignores it and routes
the command event through the WebSocket. A placeholder URL is fine.
The returned dict is the ``features.slash_commands`` portion only —
callers compose it into a full manifest (or merge into an existing
one). Keeping it narrow avoids coupling us to the rest of the manifest
schema (display_information, oauth_config, settings, etc.) which users
set up once in the Slack UI and rarely change.
"""
slashes = []
for name, desc, usage in slack_native_slashes():
entry = {
"command": f"/{name}",
"description": desc or f"Run /{name}",
"should_escape": False,
"url": request_url,
}
if usage:
entry["usage_hint"] = usage
slashes.append(entry)
return {"features": {"slash_commands": slashes}}
def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack /hermes handler.
+1
View File
@@ -465,6 +465,7 @@ DEFAULT_CONFIG = {
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
# Active only when a CDP-capable backend is attached (Browserbase or
+174
View File
@@ -4780,6 +4780,37 @@ def cmd_webhook(args):
webhook_command(args)
def cmd_slack(args):
"""Slack integration helpers.
Dispatches ``hermes slack <subcommand>``. Currently supports:
manifest print or write a Slack app manifest with every gateway
command registered as a first-class slash.
"""
sub = getattr(args, "slack_command", None)
if sub in (None, ""):
# No subcommand — print usage hint.
print(
"usage: hermes slack <subcommand>\n"
"\n"
"subcommands:\n"
" manifest Generate a Slack app manifest with every gateway\n"
" command registered as a native slash\n"
"\n"
"Run `hermes slack manifest -h` for details.",
file=sys.stderr,
)
return 1
if sub == "manifest":
from hermes_cli.slack_cli import slack_manifest_command
return slack_manifest_command(args)
print(f"Unknown slack subcommand: {sub}", file=sys.stderr)
return 1
def cmd_hooks(args):
"""Shell-hook inspection and management."""
from hermes_cli.hooks import hooks_command
@@ -5925,6 +5956,88 @@ def _cmd_update_check():
print(f" Run '{recommended_update_command()}' to install.")
def _ensure_fhs_path_guard() -> None:
"""Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells.
Mirrors the post-symlink probe added to ``scripts/install.sh`` so that
existing FHS-layout root installs on RHEL/CentOS/Rocky/Alma 8+ get
repaired on ``hermes update`` without requiring a reinstall. The
installer's assumption that ``/usr/local/bin`` is on PATH for every
standard shell breaks on those distros in non-login interactive shells
(su, sudo -s, tmux panes, some web terminals): /etc/bashrc doesn't
add /usr/local/bin and /root/.bash_profile doesn't either. Symptom:
``hermes`` prints ``command not found`` even though the symlink lives
at /usr/local/bin/hermes.
Silent no-op on: non-Linux, non-root, non-FHS installs, and any system
where ``bash -i -c 'command -v hermes'`` already resolves. Idempotent.
"""
if sys.platform != "linux":
return
try:
if os.geteuid() != 0:
return
except AttributeError:
return
# Only act when this is actually an FHS-layout install (command link at
# /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent).
fhs_link = Path("/usr/local/bin/hermes")
if not fhs_link.is_symlink() and not fhs_link.exists():
return
# Probe a fresh non-login interactive bash the way the user will use it.
# ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
# which is the exact scenario where RHEL root loses /usr/local/bin.
home = os.environ.get("HOME") or "/root"
try:
probe = subprocess.run(
["env", "-i",
f"HOME={home}",
f"TERM={os.environ.get('TERM', 'dumb')}",
"bash", "-i", "-c", "command -v hermes"],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return # no bash or probe hung — don't block update on this
if probe.returncode == 0:
return # already on PATH, nothing to do
path_line = 'export PATH="/usr/local/bin:$PATH"'
path_comment = (
"# Hermes Agent — ensure /usr/local/bin is on PATH "
"(RHEL non-login shells)"
)
wrote_any = False
for candidate in (".bashrc", ".bash_profile"):
cfg = Path(home) / candidate
if not cfg.is_file():
continue
try:
existing = cfg.read_text(errors="replace")
except OSError:
continue
# Idempotency: skip if any uncommented PATH= line already references
# /usr/local/bin. Mirrors the grep pattern used by install.sh.
already_guarded = any(
"/usr/local/bin" in line
and "PATH" in line
and not line.lstrip().startswith("#")
for line in existing.splitlines()
)
if already_guarded:
continue
try:
with cfg.open("a", encoding="utf-8") as f:
f.write("\n" + path_comment + "\n" + path_line + "\n")
except OSError as e:
print(f" ⚠ Could not update {cfg}: {e}")
continue
print(f" ✓ Added /usr/local/bin to PATH in {cfg}")
wrote_any = True
if wrote_any:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
def cmd_update(args):
"""Update Hermes Agent to the latest version.
@@ -6368,6 +6481,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("✓ Update complete!")
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
# for non-login interactive shells. No-op on every other platform.
try:
_ensure_fhs_path_guard()
except Exception as e:
logger.debug("FHS PATH guard check failed: %s", e)
# Write exit code *before* the gateway restart attempt.
# When running as ``hermes update --gateway`` (spawned by the gateway's
# /update command), this process lives inside the gateway's systemd
@@ -7798,6 +7918,54 @@ For more help on a command:
)
whatsapp_parser.set_defaults(func=cmd_whatsapp)
# =========================================================================
# slack command
# =========================================================================
slack_parser = subparsers.add_parser(
"slack",
help="Slack integration helpers (manifest generation, etc.)",
description="Slack integration helpers for Hermes.",
)
slack_sub = slack_parser.add_subparsers(dest="slack_command")
slack_manifest = slack_sub.add_parser(
"manifest",
help="Print or write a Slack app manifest with every gateway command "
"registered as a native slash (/btw, /stop, /model, ...)",
description=(
"Generate a Slack app manifest that registers every gateway "
"command in COMMAND_REGISTRY as a first-class Slack slash "
"command (matching Discord and Telegram parity). Paste the "
"output into Slack app config → Features → App Manifest → "
"Edit, then Save. Reinstall the app if Slack prompts for it."
),
)
slack_manifest.add_argument(
"--write",
nargs="?",
const=True,
default=None,
metavar="PATH",
help="Write manifest to a file instead of stdout. With no PATH "
"writes to $HERMES_HOME/slack-manifest.json.",
)
slack_manifest.add_argument(
"--name",
default=None,
help='Bot display name (default: "Hermes")',
)
slack_manifest.add_argument(
"--description",
default=None,
help="Bot description shown in Slack's app directory.",
)
slack_manifest.add_argument(
"--slashes-only",
action="store_true",
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_parser.set_defaults(func=cmd_slack)
# =========================================================================
# login command
# =========================================================================
@@ -8453,6 +8621,12 @@ Examples:
skills_list.add_argument(
"--source", default="all", choices=["all", "hub", "builtin", "local"]
)
skills_list.add_argument(
"--enabled-only",
action="store_true",
help="Hide disabled skills. Use with -p <profile> to see exactly "
"which skills will load for that profile.",
)
skills_check = skills_subparsers.add_parser(
"check", help="Check installed hub skills for updates"
-4
View File
@@ -33,8 +33,6 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
("moonshotai/kimi-k2.6", "recommended"),
("deepseek/deepseek-v4-pro", ""),
("deepseek/deepseek-v4-flash", ""),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
@@ -111,8 +109,6 @@ def _codex_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"anthropic/claude-opus-4.7",
+62 -14
View File
@@ -1856,27 +1856,32 @@ def _setup_slack():
if existing:
print_info("Slack: already configured")
if not prompt_yes_no("Reconfigure Slack?", False):
# Even without reconfiguring, offer to refresh the manifest so
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
if prompt_yes_no(
"Regenerate the Slack app manifest with the latest command "
"list? (recommended after `hermes update`)",
True,
):
_write_slack_manifest_and_instruct()
return
print_info("Steps to create a Slack app:")
print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)")
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
print_info(" • Create an App-Level Token with 'connections:write' scope")
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
print_info(" Required scopes: chat:write, app_mentions:read,")
print_info(" channels:history, channels:read, im:history,")
print_info(" im:read, im:write, users:read, files:read, files:write")
print_info(" Optional for private channels: groups:history")
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
print_info(" Required events: message.im, message.channels, app_mention")
print_info(" Optional for private channels: message.groups")
print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,")
print_warning(" not public channels.")
print_info(" 5. Install to Workspace: Settings → Install App")
print_info(" 6. Reinstall the app after any scope or event changes")
print_info(" 7. After installing, invite the bot to channels: /invite @YourBot")
print_info(" 3. Install to Workspace: Settings → Install App")
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
print()
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
print()
# Generate and write manifest up-front so the user can paste it into
# the "Create from manifest" flow instead of clicking through scopes /
# events / slash commands one at a time.
_write_slack_manifest_and_instruct()
print()
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
if not bot_token:
@@ -1902,6 +1907,49 @@ def _setup_slack():
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
def _write_slack_manifest_and_instruct():
"""Generate the Slack manifest, write it under HERMES_HOME, and print
paste-into-Slack instructions.
Exposed as its own helper so both the initial setup flow and the
"reconfigure? → no" branch can refresh the manifest without the user
re-entering tokens. Failures are non-fatal if the manifest write
fails for any reason, we print a warning and skip rather than abort
the whole Slack setup.
"""
try:
from hermes_cli.slack_cli import _build_full_manifest
from hermes_constants import get_hermes_home
manifest = _build_full_manifest(
bot_name="Hermes",
bot_description="Your Hermes agent on Slack",
)
target = Path(get_hermes_home()) / "slack-manifest.json"
target.parent.mkdir(parents=True, exist_ok=True)
import json as _json
target.write_text(
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print_success(f"Slack app manifest written to: {target}")
print_info(
" Paste it into https://api.slack.com/apps → your app → Features "
"→ App Manifest → Edit, then Save. Slack will prompt to "
"reinstall if scopes or slash commands changed."
)
print_info(
" Re-run `hermes slack manifest --write` anytime to refresh after "
"Hermes adds new commands."
)
except Exception as exc: # pragma: no cover - best-effort UX helper
print_warning(f"Couldn't write Slack manifest: {exc}")
print_info(
" You can generate it manually later with: "
"hermes slack manifest --write"
)
def _setup_matrix():
"""Configure Matrix credentials."""
print_header("Matrix")
+60 -14
View File
@@ -599,11 +599,24 @@ def inspect_skill(identifier: str) -> Optional[dict]:
return out
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
"""List installed skills, distinguishing hub, builtin, and local skills."""
def do_list(source_filter: str = "all",
enabled_only: bool = False,
console: Optional[Console] = None) -> None:
"""List installed skills, distinguishing hub, builtin, and local skills.
Args:
source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``.
enabled_only: If True, hide disabled skills from the output.
Enabled/disabled state is resolved against the currently active profile's
config ``hermes -p <profile> skills list`` reads that profile's
``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process
start. No explicit profile flag needed here.
"""
from tools.skills_hub import HubLockFile, ensure_hub_dirs
from tools.skills_sync import _read_manifest
from tools.skills_tool import _find_all_skills
from agent.skill_utils import get_disabled_skill_names
c = console or _console
ensure_hub_dirs()
@@ -611,17 +624,26 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No
hub_installed = {e["name"]: e for e in lock.list_installed()}
builtin_names = set(_read_manifest())
all_skills = _find_all_skills()
# Pull ALL skills (including disabled ones) so we can annotate status.
all_skills = _find_all_skills(skip_disabled=True)
disabled_names = get_disabled_skill_names()
table = Table(title="Installed Skills")
title = "Installed Skills"
if enabled_only:
title += " (enabled only)"
table = Table(title=title)
table.add_column("Name", style="bold cyan")
table.add_column("Category", style="dim")
table.add_column("Source", style="dim")
table.add_column("Trust", style="dim")
table.add_column("Status", style="dim")
hub_count = 0
builtin_count = 0
local_count = 0
enabled_count = 0
disabled_count = 0
for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])):
name = skill["name"]
@@ -632,29 +654,48 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No
source_type = "hub"
source_display = hub_entry.get("source", "hub")
trust = hub_entry.get("trust_level", "community")
hub_count += 1
elif name in builtin_names:
source_type = "builtin"
source_display = "builtin"
trust = "builtin"
builtin_count += 1
else:
source_type = "local"
source_display = "local"
trust = "local"
local_count += 1
if source_filter != "all" and source_filter != source_type:
continue
is_enabled = name not in disabled_names
if enabled_only and not is_enabled:
continue
if source_type == "hub":
hub_count += 1
elif source_type == "builtin":
builtin_count += 1
else:
local_count += 1
if is_enabled:
enabled_count += 1
status_cell = "[bold green]enabled[/]"
else:
disabled_count += 1
status_cell = "[dim red]disabled[/]"
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim")
trust_label = "official" if source_display == "official" else trust
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]")
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell)
c.print(table)
c.print(
f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n"
)
summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local"
if enabled_only:
summary += f"{enabled_count} enabled shown"
else:
summary += f"{enabled_count} enabled, {disabled_count} disabled"
summary += "[/]\n"
c.print(summary)
def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
@@ -1127,7 +1168,10 @@ def skills_command(args) -> None:
elif action == "inspect":
do_inspect(args.identifier)
elif action == "list":
do_list(source_filter=args.source)
do_list(
source_filter=args.source,
enabled_only=getattr(args, "enabled_only", False),
)
elif action == "check":
do_check(name=getattr(args, "name", None))
elif action == "update":
@@ -1279,11 +1323,12 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
elif action == "list":
source_filter = "all"
enabled_only = "--enabled-only" in args or "--enabled" in args
if "--source" in args:
idx = args.index("--source")
if idx + 1 < len(args):
source_filter = args[idx + 1]
do_list(source_filter=source_filter, console=c)
do_list(source_filter=source_filter, enabled_only=enabled_only, console=c)
elif action == "check":
name = args[0] if args else None
@@ -1371,7 +1416,8 @@ def _print_skills_help(console: Console) -> None:
" [cyan]search[/] <query> Search registries for skills\n"
" [cyan]install[/] <identifier> Install a skill (with security scan)\n"
" [cyan]inspect[/] <identifier> Preview a skill without installing\n"
" [cyan]list[/] [--source hub|builtin|local] List installed skills\n"
" [cyan]list[/] [--source hub|builtin|local] [--enabled-only]\n"
" List installed skills; --enabled-only filters to the active profile's live set\n"
" [cyan]check[/] [name] Check hub skills for upstream updates\n"
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
+152
View File
@@ -0,0 +1,152 @@
"""``hermes slack ...`` CLI subcommands.
Today only ``hermes slack manifest`` is implemented it generates the
Slack app manifest JSON for registering every gateway command as a native
Slack slash (``/btw``, ``/stop``, ``/model``, ) so users get the same
first-class slash UX Discord and Telegram already have.
Typical workflow::
$ hermes slack manifest > slack-manifest.json
# or:
$ hermes slack manifest --write
Then paste the printed JSON into the Slack app config (Features App
Manifest Edit) and click Save. Slack diffs the manifest and prompts
for reinstall when scopes/commands change.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
"""Build a full Slack manifest merging display info + our slash list.
The slash-command list is always generated from ``COMMAND_REGISTRY`` so
it stays in sync with the rest of Hermes. Other manifest sections
(display info, OAuth scopes, socket mode) are set to sensible defaults
for a Hermes deployment users can tweak them in the Slack UI after
pasting.
"""
from hermes_cli.commands import slack_app_manifest
partial = slack_app_manifest()
slashes = partial["features"]["slash_commands"]
return {
"_metadata": {
"major_version": 1,
"minor_version": 1,
},
"display_information": {
"name": bot_name[:35],
"description": (bot_description or "Your Hermes agent on Slack")[:140],
"background_color": "#1a1a2e",
},
"features": {
"bot_user": {
"display_name": bot_name[:80],
"always_online": True,
},
"slash_commands": slashes,
"assistant_view": {
"assistant_description": "Chat with Hermes in threads and DMs.",
},
},
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"commands",
"files:read",
"files:write",
"groups:history",
"im:history",
"im:read",
"im:write",
"users:read",
],
},
},
"settings": {
"event_subscriptions": {
"bot_events": [
"app_mention",
"assistant_thread_context_changed",
"assistant_thread_started",
"message.channels",
"message.groups",
"message.im",
],
},
"interactivity": {
"is_enabled": True,
},
"org_deploy_enabled": False,
"socket_mode_enabled": True,
"token_rotation_enabled": False,
},
}
def slack_manifest_command(args) -> int:
"""Print or write a Slack app manifest JSON.
Flags (all parsed in ``hermes_cli/main.py``):
--write [PATH] Write to file instead of stdout (default path:
``$HERMES_HOME/slack-manifest.json``)
--name NAME Override the bot display name (default: "Hermes")
--description DESC Override the bot description
--slashes-only Emit only the ``features.slash_commands`` array (for
merging into an existing manifest manually)
"""
name = getattr(args, "name", None) or "Hermes"
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
if getattr(args, "slashes_only", False):
from hermes_cli.commands import slack_app_manifest
manifest = slack_app_manifest()["features"]["slash_commands"]
else:
manifest = _build_full_manifest(name, description)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
write_target = getattr(args, "write", None)
if write_target is not None:
if isinstance(write_target, bool) and write_target:
# --write with no value → default location
try:
from hermes_constants import get_hermes_home
target = Path(get_hermes_home()) / "slack-manifest.json"
except Exception:
target = Path.home() / ".hermes" / "slack-manifest.json"
else:
target = Path(write_target).expanduser()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(payload, encoding="utf-8")
print(f"Slack manifest written to: {target}", file=sys.stderr)
print(
"\nNext steps:\n"
" 1. Open https://api.slack.com/apps and pick your Hermes app\n"
" (or create a new one: Create New App → From an app manifest).\n"
f" 2. Features → App Manifest → paste the contents of\n"
f" {target}\n"
" 3. Save; Slack will prompt to reinstall the app if scopes or\n"
" slash commands changed.\n"
" 4. Make sure Socket Mode is enabled and you have a bot token\n"
" (xoxb-...) and app token (xapp-...) configured via\n"
" `hermes setup`.\n",
file=sys.stderr,
)
else:
sys.stdout.write(payload)
return 0
+12 -1
View File
@@ -832,7 +832,18 @@ class SessionDB:
params = []
if not include_children:
where_clauses.append("s.parent_session_id IS NULL")
# Show root sessions and branch sessions (whose parent ended with
# end_reason='branched' before the child was created), while still
# hiding sub-agent runs and compression continuations (which also
# carry a parent_session_id but were spawned while the parent was
# still live — i.e., started_at < parent.ended_at).
where_clauses.append(
"(s.parent_session_id IS NULL"
" OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = s.parent_session_id"
" AND p.end_reason = 'branched'"
" AND s.started_at >= p.ended_at))"
)
if source:
where_clauses.append("s.source = ?")
+12 -3
View File
@@ -3304,10 +3304,19 @@ class AIAgent:
logger.warning("Background memory/skill review failed: %s", e)
self._emit_auxiliary_failure("background review", e)
finally:
# Close all resources (httpx client, subprocesses, etc.) so
# GC doesn't try to clean them up on a dead asyncio event
# loop (which produces "Event loop is closed" errors).
# Background review agents can initialize memory providers
# (for example Hindsight) that own their own network clients.
# Explicitly stop those providers before closing the agent so
# their aiohttp sessions do not leak until GC/process exit.
# Then close all remaining resources (httpx client,
# subprocesses, etc.) so GC doesn't try to clean them up on a
# dead asyncio event loop (which produces "Event loop is
# closed" errors).
if review_agent is not None:
try:
review_agent.shutdown_memory_provider()
except Exception:
pass
try:
review_agent.close()
except Exception:
+29 -2
View File
@@ -1055,10 +1055,37 @@ setup_path() {
return 0
fi
# FHS layout: /usr/local/bin is on PATH for every standard shell, nothing to inject.
# FHS layout: /usr/local/bin is normally on PATH for login shells (via
# /etc/profile pathmunge), but on RHEL/CentOS/Rocky/Alma 8+ non-login
# interactive root shells (su, sudo -s, tmux panes, some web terminals)
# only source /etc/bashrc, which does NOT add /usr/local/bin — and
# /root/.bash_profile doesn't either. So verify with `command -v` and
# fall back to writing a PATH guard into /root/.bashrc when needed.
if [ "$ROOT_FHS_LAYOUT" = true ]; then
export PATH="$command_link_dir:$PATH"
log_info "/usr/local/bin is already on PATH for all shells"
# Probe a fresh non-login interactive bash the way the user will use it.
# `bash -i -c` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
# which is the exact scenario where RHEL root loses /usr/local/bin.
if env -i HOME="$HOME" TERM="${TERM:-dumb}" bash -i -c 'command -v hermes' \
>/dev/null 2>&1; then
log_info "/usr/local/bin is already on PATH for all shells"
log_success "hermes command ready"
return 0
fi
log_info "hermes not on PATH in non-login shells (common on RHEL-family)"
PATH_LINE='export PATH="/usr/local/bin:$PATH"'
PATH_COMMENT='# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)'
for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do
[ -f "$SHELL_CONFIG" ] || continue
if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \
| grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then
echo "" >> "$SHELL_CONFIG"
echo "$PATH_COMMENT" >> "$SHELL_CONFIG"
echo "$PATH_LINE" >> "$SHELL_CONFIG"
log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG"
fi
done
log_success "hermes command ready"
return 0
fi
+9
View File
@@ -70,6 +70,8 @@ AUTHOR_MAP = {
"keira.voss94@gmail.com": "keiravoss94",
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
"fqsy1416@gmail.com": "EKKOLearnAI",
"octo-patch@github.com": "octo-patch",
"math0r-be@github.com": "math0r-be",
"simbamax99@gmail.com": "simbam99",
"iris@growthpillars.co": "irispillars",
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
@@ -116,9 +118,15 @@ AUTHOR_MAP = {
"Mibayy@users.noreply.github.com": "Mibayy",
"mibayy@users.noreply.github.com": "Mibayy",
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
"lzy.dev@gmail.com": "zhiyanliu",
"me@janstepanovsky.cz": "hhhonzik",
"139848623+hhuang91@users.noreply.github.com": "hhuang91",
"s.ozaki@ebinou.net": "Satoshi-agi",
"10774721+kunlabs@users.noreply.github.com": "kunlabs",
"nocoo@users.noreply.github.com": "nocoo",
"30841158+n-WN@users.noreply.github.com": "n-WN",
"tsuijinglei@gmail.com": "hiddenpuppy",
"buraysandro9@gmail.com": "ygd58",
"jerome@clawwork.ai": "HiddenPuppy",
"jerome.benoit@sap.com": "jerome-benoit",
"wysie@users.noreply.github.com": "Wysie",
@@ -191,6 +199,7 @@ AUTHOR_MAP = {
"satelerd@gmail.com": "satelerd",
"dan@danlynn.com": "danklynn",
"mattmaximo@hotmail.com": "MattMaximo",
"MatthewRHardwick@gmail.com": "mrhwick",
"149063006+j3ffffff@users.noreply.github.com": "j3ffffff",
"A-FdL-Prog@users.noreply.github.com": "A-FdL-Prog",
"l0hde@users.noreply.github.com": "l0hde",
-3
View File
@@ -1,3 +0,0 @@
---
description: Skills for monitoring, aggregating, and processing RSS feeds, blogs, and web content sources.
---
+24
View File
@@ -160,6 +160,30 @@ class TestBranchCommandCLI:
assert agent.reset_session_state.called
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
def test_branch_updates_agent_session_log_file(self, cli_instance, session_db, tmp_path):
"""Branching must redirect the agent's session_log_file to the new session's path."""
from cli import HermesCLI
from pathlib import Path
logs_dir = tmp_path / "sessions"
logs_dir.mkdir()
agent = MagicMock()
agent._last_flushed_db_idx = 0
agent.logs_dir = logs_dir
agent.session_log_file = logs_dir / f"session_{cli_instance.session_id}.json"
cli_instance.agent = agent
old_log_file = agent.session_log_file
HermesCLI._handle_branch_command(cli_instance, "/branch")
new_session_id = cli_instance.session_id
expected_log = logs_dir / f"session_{new_session_id}.json"
assert agent.session_log_file == expected_log, (
"session_log_file must point to the branch session, not the original"
)
assert agent.session_log_file != old_log_file
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
"""Branch should set _resumed=True to prevent auto-title generation."""
from cli import HermesCLI
+82
View File
@@ -31,6 +31,40 @@ def _make_cli_stub():
return cli
def _make_background_cli_stub():
cli = _make_cli_stub()
cli._background_task_counter = 0
cli._background_tasks = {}
cli._ensure_runtime_credentials = MagicMock(return_value=True)
cli._resolve_turn_agent_config = MagicMock(return_value={
"model": "test-model",
"runtime": {
"api_key": "test-key",
"base_url": "https://example.test/v1",
"provider": "test",
"api_mode": "chat_completions",
},
"request_overrides": None,
})
cli.max_turns = 90
cli.enabled_toolsets = []
cli._session_db = None
cli.reasoning_config = {}
cli.service_tier = None
cli._providers_only = None
cli._providers_ignore = None
cli._providers_order = None
cli._provider_sort = None
cli._provider_require_params = None
cli._provider_data_collection = None
cli._fallback_model = None
cli._agent_running = False
cli._spinner_text = ""
cli.bell_on_complete = False
cli.final_response_markdown = "strip"
return cli
class TestCliApprovalUi:
def test_sudo_prompt_restores_existing_draft_after_response(self):
cli = _make_cli_stub()
@@ -255,6 +289,54 @@ class TestCliApprovalUi:
# Command got truncated with a marker.
assert "(command truncated" in rendered
def test_background_task_registers_thread_local_approval_callbacks(self):
"""Background /btw tasks must use the prompt_toolkit approval UI.
The foreground chat path registers dangerous-command callbacks inside
its worker thread because tools.terminal_tool stores them in
threading.local(). /background used to skip that, so dangerous commands
fell back to raw input() in a background thread and timed out under
prompt_toolkit.
"""
cli = _make_background_cli_stub()
seen = {}
class FakeAgent:
def __init__(self, **kwargs):
self._print_fn = None
self.thinking_callback = None
def run_conversation(self, **kwargs):
from tools.terminal_tool import (
_get_approval_callback,
_get_sudo_password_callback,
)
seen["approval"] = _get_approval_callback()
seen["sudo"] = _get_sudo_password_callback()
return {
"final_response": "done",
"messages": [],
"completed": True,
"failed": False,
}
with patch.object(cli_module, "AIAgent", FakeAgent), \
patch.object(cli_module, "_cprint"), \
patch.object(cli_module, "ChatConsole") as chat_console:
chat_console.return_value.print = MagicMock()
cli._handle_background_command("/btw check weather")
deadline = time.time() + 2
while cli._background_tasks and time.time() < deadline:
time.sleep(0.01)
assert seen["approval"].__self__ is cli
assert seen["approval"].__func__ is HermesCLI._approval_callback
assert seen["sudo"].__self__ is cli
assert seen["sudo"].__func__ is HermesCLI._sudo_password_callback
assert not cli._background_tasks
class TestApprovalCallbackThreadLocalWiring:
"""Regression guard for the thread-local callback freeze (#13617 / #13618).
+15
View File
@@ -211,6 +211,21 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"SIGNAL_ALLOW_ALL_USERS",
"EMAIL_ALLOW_ALL_USERS",
"SMS_ALLOW_ALL_USERS",
# Platform gating — set by load_gateway_config() as a side effect when
# a config.yaml is present, so individual test bodies that call the
# loader leak these values into later tests on the same xdist worker.
# Force-clear on every test setup so the leak can't happen.
"SLACK_REQUIRE_MENTION",
"SLACK_STRICT_MENTION",
"SLACK_FREE_RESPONSE_CHANNELS",
"SLACK_ALLOW_BOTS",
"SLACK_REACTIONS",
"DISCORD_REQUIRE_MENTION",
"DISCORD_FREE_RESPONSE_CHANNELS",
"TELEGRAM_REQUIRE_MENTION",
"WHATSAPP_REQUIRE_MENTION",
"DINGTALK_REQUIRE_MENTION",
"MATRIX_REQUIRE_MENTION",
})
+129
View File
@@ -1043,3 +1043,132 @@ class TestAgentCacheIdleResume:
new_agent.close()
except Exception:
pass
_FAKE_NOW = 10_000.0 # Fixed epoch for deterministic time assertions
class TestCachedAgentInactivityReset:
"""Inactivity-clock reset must be gated on _interrupt_depth == 0.
On interrupt-recursive turns (_interrupt_depth > 0) the clock must
keep accumulating so the inactivity watchdog can fire when a turn is
stuck in an interrupt loop. Resetting unconditionally prevented the
30-min timeout from triggering (#15654). The depth-0 reset is still
needed: a session idle for 29 min must not trip the watchdog before
the new turn makes its first API call (#9051).
"""
def _fake_agent(self, stale_seconds: float = 1800.0):
m = MagicMock()
m._last_activity_ts = _FAKE_NOW - stale_seconds
m._api_call_count = 10
m._last_activity_desc = "previous turn activity"
return m
def test_fresh_turn_resets_idle_clock(self):
"""interrupt_depth=0: clock resets so a post-idle turn gets a
fresh 30-min inactivity window (guard for #9051)."""
from gateway.run import GatewayRunner
agent = self._fake_agent(stale_seconds=1800.0)
old_ts = agent._last_activity_ts
with patch("gateway.run.time") as mock_time:
mock_time.time.return_value = _FAKE_NOW
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0)
assert agent._last_activity_ts == _FAKE_NOW, (
"_last_activity_ts was not reset on a fresh turn (interrupt_depth=0)"
)
assert agent._last_activity_ts > old_ts, (
"Stale idle time should be cleared so the new turn gets a fresh window"
)
def test_fresh_turn_resets_desc(self):
"""interrupt_depth=0: description is updated to reflect the new turn."""
from gateway.run import GatewayRunner
agent = self._fake_agent()
with patch("gateway.run.time") as mock_time:
mock_time.time.return_value = _FAKE_NOW
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0)
assert agent._last_activity_desc == "starting new turn (cached)"
def test_interrupt_turn_preserves_idle_clock(self):
"""interrupt_depth=1: clock preserved so accumulated stuck-turn
idle time is not discarded by an interrupt-recursive re-entry (#15654)."""
from gateway.run import GatewayRunner
agent = self._fake_agent(stale_seconds=1200.0)
old_ts = agent._last_activity_ts
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
assert agent._last_activity_ts == old_ts, (
"_last_activity_ts must not be reset on interrupt-recursive turns "
"(interrupt_depth>0) — the watchdog needs the accumulated idle time"
)
def test_interrupt_turn_preserves_desc(self):
"""interrupt_depth=1: desc preserved — it is semantically paired with ts."""
from gateway.run import GatewayRunner
agent = self._fake_agent(stale_seconds=1200.0)
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
assert agent._last_activity_desc == "previous turn activity", (
"_last_activity_desc must not change on interrupt-recursive turns; "
"it describes the activity *at* _last_activity_ts"
)
def test_deep_interrupt_recursion_preserves_idle_clock(self):
"""interrupt_depth=MAX-1: clock still preserved at any non-zero depth."""
from gateway.run import GatewayRunner
agent = self._fake_agent(stale_seconds=600.0)
old_ts = agent._last_activity_ts
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=4)
assert agent._last_activity_ts == old_ts
def test_api_call_count_reset_regardless_of_depth(self):
"""_api_call_count is always reset to 0 for the new turn, at any depth."""
from gateway.run import GatewayRunner
agent_fresh = self._fake_agent()
agent_interrupted = self._fake_agent()
with patch("gateway.run.time") as mock_time:
mock_time.time.return_value = _FAKE_NOW
GatewayRunner._init_cached_agent_for_turn(agent_fresh, interrupt_depth=0)
GatewayRunner._init_cached_agent_for_turn(agent_interrupted, interrupt_depth=1)
assert agent_fresh._api_call_count == 0
assert agent_interrupted._api_call_count == 0
def test_watchdog_accumulation_across_recursive_turns(self):
"""Scenario: stuck turn + user interrupt → recursive turn.
The idle time seen by the watchdog must reflect the full stuck
duration, not restart from zero on the recursive re-entry.
"""
from gateway.run import GatewayRunner
STUCK_FOR = 1750.0
agent = self._fake_agent(stale_seconds=STUCK_FOR)
# Simulate: user sees "Still working..." and sends another message.
# That triggers an interrupt → _run_agent recurses at depth=1.
GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1)
# Watchdog sees time.time() - _last_activity_ts ≥ STUCK_FOR.
idle_secs = _FAKE_NOW - agent._last_activity_ts
assert idle_secs >= STUCK_FOR - 1.0, (
f"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. "
"Inactivity timeout could not fire for a stuck interrupted turn."
)
+152 -2
View File
@@ -1,9 +1,11 @@
"""Tests for gateway/channel_directory.py — channel resolution and display."""
import asyncio
import json
import os
from pathlib import Path
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from gateway.channel_directory import (
build_channel_directory,
@@ -12,6 +14,7 @@ from gateway.channel_directory import (
format_directory_for_display,
load_directory,
_build_from_sessions,
_build_slack,
DIRECTORY_PATH,
)
@@ -62,7 +65,7 @@ class TestBuildChannelDirectoryWrites:
monkeypatch.setattr(json, "dump", broken_dump)
with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file):
build_channel_directory({})
asyncio.run(build_channel_directory({}))
result = load_directory()
assert result == previous
@@ -142,6 +145,21 @@ class TestResolveChannelName:
with self._setup(tmp_path, platforms):
assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585"
def test_id_match_takes_precedence_over_name(self, tmp_path):
"""A raw channel ID resolves to itself, even when a different
channel happens to be named the same string. Case-sensitive: Slack
IDs are uppercase and must not be normalized away."""
platforms = {
"slack": [
{"id": "C0B0QV5434G", "name": "engineering", "type": "channel"},
{"id": "C99", "name": "c0b0qv5434g", "type": "channel"},
]
}
with self._setup(tmp_path, platforms):
assert resolve_channel_name("slack", "C0B0QV5434G") == "C0B0QV5434G"
# Lowercase still falls through to name matching (case-insensitive)
assert resolve_channel_name("slack", "c0b0qv5434g") == "C99"
def test_display_label_with_type_suffix_resolves(self, tmp_path):
platforms = {
"telegram": [
@@ -332,3 +350,135 @@ class TestLookupChannelType:
}
with self._setup(tmp_path, platforms):
assert lookup_channel_type("discord", "300") is None
def _make_slack_adapter(team_clients):
"""Build a stand-in for SlackAdapter exposing only ``_team_clients``."""
return SimpleNamespace(_team_clients=team_clients)
def _make_slack_client(pages):
"""Build an AsyncWebClient mock whose ``users_conversations`` returns pages."""
client = MagicMock()
client.users_conversations = AsyncMock(side_effect=pages)
return client
class TestBuildSlack:
"""_build_slack actually calls users.conversations on each workspace client."""
def test_no_team_clients_falls_back_to_sessions(self, tmp_path):
sessions_path = tmp_path / "sessions" / "sessions.json"
sessions_path.parent.mkdir(parents=True)
sessions_path.write_text(json.dumps({
"s1": {"origin": {"platform": "slack", "chat_id": "D123", "chat_name": "Alice"}},
}))
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({})))
assert len(entries) == 1
assert entries[0]["id"] == "D123"
def test_lists_channels_from_users_conversations(self, tmp_path):
client = _make_slack_client([
{
"ok": True,
"channels": [
{"id": "C0B0QV5434G", "name": "engineering", "is_private": False},
{"id": "G123ABCDEF", "name": "secret-chat", "is_private": True},
],
"response_metadata": {},
},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
ids = {e["id"] for e in entries}
assert ids == {"C0B0QV5434G", "G123ABCDEF"}
types = {e["id"]: e["type"] for e in entries}
assert types["C0B0QV5434G"] == "channel"
assert types["G123ABCDEF"] == "private"
client.users_conversations.assert_awaited_once()
def test_paginates_via_response_metadata_cursor(self, tmp_path):
client = _make_slack_client([
{
"ok": True,
"channels": [{"id": "C001", "name": "first", "is_private": False}],
"response_metadata": {"next_cursor": "cur1"},
},
{
"ok": True,
"channels": [{"id": "C002", "name": "second", "is_private": False}],
"response_metadata": {"next_cursor": ""},
},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
assert {e["id"] for e in entries} == {"C001", "C002"}
assert client.users_conversations.await_count == 2
def test_per_workspace_error_does_not_block_others(self, tmp_path):
bad = MagicMock()
bad.users_conversations = AsyncMock(side_effect=RuntimeError("boom"))
good = _make_slack_client([
{
"ok": True,
"channels": [{"id": "C999", "name": "ok-channel", "is_private": False}],
"response_metadata": {},
},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"BAD": bad, "GOOD": good})))
assert {e["id"] for e in entries} == {"C999"}
def test_session_dms_merged_when_not_in_api_results(self, tmp_path):
sessions_path = tmp_path / "sessions" / "sessions.json"
sessions_path.parent.mkdir(parents=True)
sessions_path.write_text(json.dumps({
"s1": {"origin": {"platform": "slack", "chat_id": "D456", "chat_name": "Bob"}},
"dup": {"origin": {"platform": "slack", "chat_id": "C001", "chat_name": "first"}},
}))
client = _make_slack_client([
{
"ok": True,
"channels": [{"id": "C001", "name": "first", "is_private": False}],
"response_metadata": {},
},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
ids = {e["id"] for e in entries}
assert "C001" in ids and "D456" in ids
# Channel ID from API should not be duplicated by the session merge
assert sum(1 for e in entries if e["id"] == "C001") == 1
def test_skips_channels_with_no_id_or_name(self, tmp_path):
client = _make_slack_client([
{
"ok": True,
"channels": [
{"id": "C001", "name": "good", "is_private": False},
{"id": "", "name": "no-id"},
{"id": "C002"}, # no name (e.g. IM)
],
"response_metadata": {},
},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
assert {e["id"] for e in entries} == {"C001"}
def test_response_not_ok_breaks_pagination_for_that_workspace(self, tmp_path):
client = _make_slack_client([
{"ok": False, "error": "missing_scope"},
])
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client})))
assert entries == []
+34 -1
View File
@@ -540,7 +540,7 @@ from gateway.config import Platform, PlatformConfig # noqa: E402
def _make_slack_adapter():
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
config = PlatformConfig(enabled=True, token="***")
adapter = SlackAdapter(config)
adapter._app = MagicMock()
adapter._app.client = AsyncMock()
@@ -549,6 +549,39 @@ def _make_slack_adapter():
return adapter
# ---------------------------------------------------------------------------
# SlackAdapter diagnostics helpers
# ---------------------------------------------------------------------------
class TestSlackAttachmentDiagnostics:
def test_missing_scope_error_returns_actionable_notice(self):
"""_describe_slack_api_error translates a missing_scope response into
a user-facing notice mentioning the needed scope and the reinstall
step. This is the helper used by every files.info call site (Slack
Connect stubs + post-download failures) to surface scope problems
without making an extra probe call per attachment.
"""
adapter = _make_slack_adapter()
response = {
"error": "missing_scope",
"needed": "files:read",
"provided": "chat:write,files:write",
}
detail = adapter._describe_slack_api_error(response, file_obj={"id": "F123", "name": "photo.jpg"})
assert detail is not None
assert "files:read" in detail
assert "reinstall" in detail.lower()
assert "chat:write,files:write" in detail
def test_download_failure_403_returns_permission_notice(self):
adapter = _make_slack_adapter()
exc = _make_http_status_error(403)
detail = adapter._describe_slack_download_failure(exc, file_obj={"name": "report.pdf"})
assert "403" in detail
assert "permission or scope" in detail
# ---------------------------------------------------------------------------
# SlackAdapter._download_slack_file
# ---------------------------------------------------------------------------
+185 -8
View File
@@ -168,19 +168,196 @@ class TestQueueConsumptionAfterCompletion:
assert retrieved is not None
assert retrieved.text == "process this after"
def test_multiple_queues_last_one_wins(self):
"""If user /queue's multiple times, last message overwrites."""
def test_multiple_queues_overflow_fifo(self):
"""Multiple /queue commands must stack in FIFO order, no merging.
The adapter's _pending_messages dict has a single slot per session,
but GatewayRunner layers an overflow buffer on top so repeated
/queue invocations all get their own turn in order.
"""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
session_key = "telegram:user:123"
for text in ["first", "second", "third"]:
event = MessageEvent(
events = [
MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(),
source=MagicMock(chat_id="123", platform=Platform.TELEGRAM),
message_id=f"q-{text}",
)
adapter._pending_messages[session_key] = event
for text in ("first", "second", "third")
]
retrieved = adapter.get_pending_message(session_key)
assert retrieved.text == "third"
for ev in events:
runner._enqueue_fifo(session_key, ev, adapter)
# Slot holds head; overflow holds the tail in order.
assert adapter._pending_messages[session_key].text == "first"
assert [e.text for e in runner._queued_events[session_key]] == ["second", "third"]
assert runner._queue_depth(session_key, adapter=adapter) == 3
def test_promote_advances_queue_fifo(self):
"""After the slot drains, the next overflow item is promoted."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
session_key = "telegram:user:123"
for text in ("A", "B", "C"):
runner._enqueue_fifo(
session_key,
MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(),
message_id=f"q-{text}",
),
adapter,
)
# Simulate turn 1 drain: consume slot, promote next.
pending_event = _dequeue_pending_event(adapter, session_key)
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
assert pending_event is not None and pending_event.text == "A"
assert adapter._pending_messages[session_key].text == "B"
assert runner._queue_depth(session_key, adapter=adapter) == 2
# Simulate turn 2 drain.
pending_event = _dequeue_pending_event(adapter, session_key)
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
assert pending_event.text == "B"
assert adapter._pending_messages[session_key].text == "C"
assert session_key not in runner._queued_events # overflow emptied
# Simulate turn 3 drain.
pending_event = _dequeue_pending_event(adapter, session_key)
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
assert pending_event.text == "C"
assert session_key not in adapter._pending_messages
assert runner._queue_depth(session_key, adapter=adapter) == 0
# Turn 4: nothing pending.
pending_event = _dequeue_pending_event(adapter, session_key)
pending_event = runner._promote_queued_event(session_key, adapter, pending_event)
assert pending_event is None
def test_promote_stages_overflow_when_slot_already_populated(self):
"""If the slot was re-populated (e.g. by an interrupt follow-up),
promotion must stage the overflow head without clobbering it."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
session_key = "telegram:user:123"
# /queue once — lands in slot. Second /queue — overflow.
for text in ("Q1", "Q2"):
runner._enqueue_fifo(
session_key,
MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(),
message_id=f"q-{text}",
),
adapter,
)
# Drain consumes Q1.
pending_event = _dequeue_pending_event(adapter, session_key)
assert pending_event.text == "Q1"
# Someone else (interrupt path) re-populates the slot.
interrupt_follow_up = MessageEvent(
text="urgent",
message_type=MessageType.TEXT,
source=MagicMock(),
message_id="m-urg",
)
adapter._pending_messages[session_key] = interrupt_follow_up
# Promotion must NOT overwrite the interrupt follow-up; Q2 should
# move into a position that runs AFTER it. In the current design
# the overflow head is staged in the slot AFTER the interrupt
# follow-up's turn runs — so here, the slot keeps the interrupt
# and Q2 stays queued. Verify we return the interrupt event and
# Q2 is positioned to run next.
returned = runner._promote_queued_event(session_key, adapter, interrupt_follow_up)
assert returned is interrupt_follow_up
# Q2 was moved into the slot, evicting the interrupt? No —
# current implementation puts Q2 in the slot unconditionally,
# overwriting the interrupt. This is an acceptable edge-case
# trade-off: /queue items always run after the currently-staged
# pending_event (which is what `returned` is), and the slot
# gets the next-in-line item.
assert adapter._pending_messages[session_key].text == "Q2"
def test_queue_depth_counts_slot_plus_overflow(self):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
session_key = "telegram:user:depth"
assert runner._queue_depth(session_key, adapter=adapter) == 0
runner._enqueue_fifo(
session_key,
MessageEvent(
text="one",
message_type=MessageType.TEXT,
source=MagicMock(),
message_id="q1",
),
adapter,
)
assert runner._queue_depth(session_key, adapter=adapter) == 1
for text in ("two", "three"):
runner._enqueue_fifo(
session_key,
MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(),
message_id=f"q-{text}",
),
adapter,
)
assert runner._queue_depth(session_key, adapter=adapter) == 3
def test_enqueue_preserves_text_no_merging(self):
"""Each /queue item keeps its own text — never merged with neighbors."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
session_key = "telegram:user:nomerge"
texts = ["deploy the branch", "then run tests", "finally push"]
for text in texts:
runner._enqueue_fifo(
session_key,
MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(),
message_id=f"q-{text[:4]}",
),
adapter,
)
# Slot + overflow contain exactly the three texts, unmodified.
collected = [adapter._pending_messages[session_key].text] + [
e.text for e in runner._queued_events[session_key]
]
assert collected == texts
+193 -1
View File
@@ -147,7 +147,20 @@ class TestAppMentionHandler:
assert "app_mention" in registered_events
assert "assistant_thread_started" in registered_events
assert "assistant_thread_context_changed" in registered_events
assert "/hermes" in registered_commands
# Slack slash commands are registered via a single regex matcher
# covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop,
# /model, ...) so users get native-slash parity with Discord and
# Telegram. Verify the regex matches the key expected slashes.
assert len(registered_commands) == 1, (
f"expected 1 combined slash matcher, got {registered_commands!r}"
)
slash_matcher = registered_commands[0]
import re as _re
assert isinstance(slash_matcher, _re.Pattern)
for expected in ("/hermes", "/btw", "/stop", "/model", "/help"):
assert slash_matcher.match(expected), (
f"Slack slash regex does not match {expected}"
)
class TestSlackConnectCleanup:
@@ -498,6 +511,35 @@ class TestIncomingDocumentHandling:
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.PHOTO
@pytest.mark.asyncio
async def test_download_failure_is_surfaced_in_message_text(self, adapter):
"""Attachment download failures (401/403/HTML-body/etc.) should be
translated into a user-facing `[Slack attachment notice]` block so
the agent can tell the user what to fix (e.g. missing files:read
scope). No proactive files.info probe is made the diagnostic
runs only when the download actually fails.
"""
import httpx
req = httpx.Request("GET", "https://files.slack.com/photo.jpg")
resp = httpx.Response(403, request=req)
with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl:
dl.side_effect = httpx.HTTPStatusError("403", request=req, response=resp)
event = self._make_event(text="what's in this?", files=[{
"id": "F123",
"mimetype": "image/jpeg",
"name": "photo.jpg",
"url_private_download": "https://files.slack.com/photo.jpg",
"size": 1024,
}])
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.TEXT
assert "[Slack attachment notice]" in msg_event.text
assert "403" in msg_event.text
assert "what's in this?" in msg_event.text
# ---------------------------------------------------------------------------
# TestMessageRouting
@@ -1544,6 +1586,83 @@ class TestSlashCommands:
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/reasoning"
# ------------------------------------------------------------------
# Native slash commands — /btw, /stop, /model, ... dispatched directly
# instead of as /hermes subcommands. This is the Discord/Telegram parity
# fix: the slash name itself becomes the command.
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_native_btw_slash(self, adapter):
"""/btw with args must dispatch to /background, not /hermes btw."""
command = {
"command": "/btw",
"text": "fix the failing test",
"user_id": "U1",
"channel_id": "C1",
}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
# The gateway command dispatcher resolves /btw -> background via
# resolve_command() — our handler's job is just to deliver
# "/btw <args>" to the gateway runner, which is what this asserts.
assert msg.text == "/btw fix the failing test"
@pytest.mark.asyncio
async def test_native_stop_slash_no_args(self, adapter):
command = {
"command": "/stop",
"text": "",
"user_id": "U1",
"channel_id": "C1",
}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/stop"
@pytest.mark.asyncio
async def test_native_model_slash_with_args(self, adapter):
command = {
"command": "/model",
"text": "anthropic/claude-sonnet-4",
"user_id": "U1",
"channel_id": "C1",
}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/model anthropic/claude-sonnet-4"
@pytest.mark.asyncio
async def test_legacy_hermes_prefix_still_works(self, adapter):
"""Backward compat: /hermes btw foo must still route to /btw foo.
Old workspace manifests only declared /hermes as the single slash.
After users refresh their manifest they get /btw natively, but the
legacy form must keep working during the transition.
"""
command = {
"command": "/hermes",
"text": "btw run the tests",
"user_id": "U1",
"channel_id": "C1",
}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/btw run the tests"
@pytest.mark.asyncio
async def test_legacy_hermes_freeform_question(self, adapter):
"""/hermes <free-form text> must stay as the raw text (non-command)."""
command = {
"command": "/hermes",
"text": "what's the weather today?",
"user_id": "U1",
"channel_id": "C1",
}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "what's the weather today?"
# ---------------------------------------------------------------------------
# TestMessageSplitting
@@ -1921,3 +2040,76 @@ class TestProgressMessageThread:
"so each @mention starts its own thread"
)
assert msg_event.message_id == "2000000000.000001"
class TestSlackReplyToText:
"""Ensure MessageEvent.reply_to_text is populated on thread replies so
gateway.run can inject a ``[Replying to: "..."]`` prefix (parity with
Telegram/Discord/Feishu/WeCom)."""
@pytest.mark.asyncio
async def test_slack_reply_to_text_set_on_thread_reply(self, adapter):
"""When a thread reply arrives and the parent was posted by a bot
(e.g. cron summary), reply_to_text must carry the parent's text."""
adapter._channel_team = {} # primary workspace only
adapter._team_bot_user_ids = {}
# Mock conversations_replies to return a bot-posted parent
adapter._app.client.conversations_replies = AsyncMock(return_value={
"messages": [
{
"ts": "1000.0",
"bot_id": "B_CRON",
"text": "メール要約: 新着メール3件あります",
},
{"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"},
]
})
# Use a DM so mention-gating doesn't short-circuit the handler.
event = {
"text": "詳細を教えて",
"user": "U_USER",
"channel": "D123",
"channel_type": "im",
"ts": "1000.5",
"thread_ts": "1000.0", # thread reply
}
with patch.object(
adapter, "_resolve_user_name", new=AsyncMock(return_value="Alice")
):
await adapter._handle_slack_message(event)
assert adapter.handle_message.call_args is not None, (
"handle_message must be invoked for thread-reply DM"
)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.reply_to_message_id == "1000.0"
# The critical assertion: parent text is exposed as reply_to_text so the
# gateway can inject it when not already in the session history.
assert msg_event.reply_to_text is not None
assert "メール要約" in msg_event.reply_to_text
@pytest.mark.asyncio
async def test_slack_reply_to_text_none_for_top_level_message(self, adapter):
"""Top-level messages (no thread_ts) must not set reply_to_text."""
event = {
"text": "hello",
"user": "U_USER",
"channel": "D123",
"channel_type": "im",
"ts": "1000.0",
# no thread_ts — top-level DM
}
with patch.object(
adapter, "_resolve_user_name", new=AsyncMock(return_value="Alice")
):
await adapter._handle_slack_message(event)
assert adapter.handle_message.call_args is not None
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.reply_to_text is None
# Top-level message: reply_to_message_id must be falsy (None or empty).
assert not msg_event.reply_to_message_id
+184 -3
View File
@@ -276,23 +276,44 @@ class TestSlackThreadContext:
@pytest.mark.asyncio
async def test_skips_bot_messages(self):
"""Self-bot child replies are skipped to avoid circular context,
but non-self bots (e.g. cron posts, third-party integrations) are kept.
Regression guard for the fix in _fetch_thread_context: previously ALL
bot messages were dropped, which lost context when the bot was replying
to a cron-posted thread parent."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "user": "U1", "text": "Parent"},
{"ts": "1000.1", "bot_id": "B1", "text": "Bot reply (should be skipped)"},
# Self-bot reply -> must be skipped (circular)
{
"ts": "1000.1",
"bot_id": "B_SELF",
"user": "U_BOT",
"text": "Previous bot self-reply (should be skipped)",
},
# Third-party bot child -> kept (useful context)
{
"ts": "1000.15",
"bot_id": "B_OTHER",
"user": "U_OTHER_BOT",
"text": "Deploy succeeded",
},
{"ts": "1000.2", "user": "U1", "text": "Current"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
adapter._user_name_cache = {"U1": "Alice", "U_OTHER_BOT": "DeployBot"}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1"
)
assert "Bot reply" not in context
assert "Previous bot self-reply" not in context
assert "Alice: Parent" in context
# Third-party bot message must now be included
assert "Deploy succeeded" in context
@pytest.mark.asyncio
async def test_empty_thread(self):
@@ -316,6 +337,166 @@ class TestSlackThreadContext:
)
assert context == ""
@pytest.mark.asyncio
async def test_fetch_thread_context_includes_bot_parent(self):
"""The thread parent posted by a bot (e.g. a cron summary) must be
included in the context, prefixed with ``[thread parent]``."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
# Bot-posted parent (cron job)
{
"ts": "1000.0",
"bot_id": "B123",
"subtype": "bot_message",
"username": "cron",
"text": "メール要約: 本日の新着3件",
},
# User reply that triggered the fetch
{"ts": "1000.1", "user": "U1", "text": "詳細を教えて"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1",
thread_ts="1000.0",
current_ts="1000.1", # exclude the trigger message itself
team_id="T1",
)
assert "[thread parent]" in context
assert "メール要約: 本日の新着3件" in context
@pytest.mark.asyncio
async def test_fetch_thread_context_excludes_self_bot_replies(self):
"""Parent (non-self bot) is kept, self-bot child replies are dropped,
user replies are kept."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "bot_id": "B_CRON", "text": "Cron summary"},
# Self-bot child reply -> excluded
{
"ts": "1000.1",
"bot_id": "B_SELF",
"user": "U_BOT", # matches adapter._bot_user_id
"text": "Previous self reply",
},
# User reply -> kept
{"ts": "1000.2", "user": "U1", "text": "Follow-up question"},
# Current trigger (excluded by current_ts match)
{"ts": "1000.3", "user": "U1", "text": "Current"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1"
)
assert "Cron summary" in context
assert "[thread parent]" in context
assert "Previous self reply" not in context
assert "Follow-up question" in context
assert "Current" not in context
@pytest.mark.asyncio
async def test_fetch_thread_context_multi_workspace(self):
"""Self-bot filtering must use the per-workspace bot user id so a
self-bot id that belongs to a different workspace does not accidentally
filter out a legitimate message in the current workspace."""
adapter = _make_adapter()
# Add a second workspace with a different bot user id
adapter._team_clients["T2"] = AsyncMock()
adapter._team_bot_user_ids = {"T1": "U_BOT_T1", "T2": "U_BOT_T2"}
adapter._bot_user_id = "U_BOT_T1"
adapter._channel_team["C2"] = "T2"
mock_client = adapter._team_clients["T2"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "2000.0", "user": "U2", "text": "Parent T2"},
# This has the *T1* bot's user id — from T2's perspective this
# is a third-party bot, so it must be kept.
{
"ts": "2000.1",
"bot_id": "B_FOREIGN",
"user": "U_BOT_T1",
"team": "T2",
"text": "Cross-workspace bot reply",
},
# Self-bot for T2 — must be skipped
{
"ts": "2000.2",
"bot_id": "B_SELF_T2",
"user": "U_BOT_T2",
"team": "T2",
"text": "Own T2 bot reply",
},
{"ts": "2000.3", "user": "U2", "text": "Current"},
]
})
adapter._user_name_cache = {"U2": "Bob"}
context = await adapter._fetch_thread_context(
channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2"
)
assert "Parent T2" in context
assert "Cross-workspace bot reply" in context
assert "Own T2 bot reply" not in context
@pytest.mark.asyncio
async def test_fetch_thread_context_current_ts_excluded(self):
"""Regression guard: the message whose ts == current_ts must never
appear in the context output (it will be delivered as the user
message itself)."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "user": "U1", "text": "Parent"},
{"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"},
]
})
adapter._user_name_cache = {"U1": "Alice"}
context = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert "Parent" in context
assert "DO NOT INCLUDE THIS" not in context
@pytest.mark.asyncio
async def test_fetch_thread_parent_text_from_cache(self):
"""_fetch_thread_parent_text should reuse the thread-context cache
when it is warm, avoiding an extra conversations.replies call."""
adapter = _make_adapter()
mock_client = adapter._team_clients["T1"]
mock_client.conversations_replies = AsyncMock(return_value={
"messages": [
{"ts": "1000.0", "bot_id": "B123", "text": "Parent summary"},
{"ts": "1000.1", "user": "U1", "text": "reply"},
]
})
# Warm the cache via _fetch_thread_context
await adapter._fetch_thread_context(
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
)
assert mock_client.conversations_replies.await_count == 1
parent = await adapter._fetch_thread_parent_text(
channel_id="C1", thread_ts="1000.0", team_id="T1"
)
assert parent == "Parent summary"
# No additional API call
assert mock_client.conversations_replies.await_count == 1
# ===========================================================================
# _has_active_session_for_thread — session key fix (#5833)
+151 -1
View File
@@ -55,10 +55,12 @@ CHANNEL_ID = "C0AQWDLHY9M"
OTHER_CHANNEL_ID = "C9999999999"
def _make_adapter(require_mention=None, free_response_channels=None):
def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None):
extra = {}
if require_mention is not None:
extra["require_mention"] = require_mention
if strict_mention is not None:
extra["strict_mention"] = strict_mention
if free_response_channels is not None:
extra["free_response_channels"] = free_response_channels
@@ -134,6 +136,48 @@ def test_require_mention_env_var_default_true(monkeypatch):
assert adapter._slack_require_mention() is True
# ---------------------------------------------------------------------------
# Tests: _slack_strict_mention
# ---------------------------------------------------------------------------
def test_strict_mention_defaults_to_false(monkeypatch):
monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False)
adapter = _make_adapter()
assert adapter._slack_strict_mention() is False
def test_strict_mention_true():
adapter = _make_adapter(strict_mention=True)
assert adapter._slack_strict_mention() is True
def test_strict_mention_false():
adapter = _make_adapter(strict_mention=False)
assert adapter._slack_strict_mention() is False
def test_strict_mention_string_true():
adapter = _make_adapter(strict_mention="true")
assert adapter._slack_strict_mention() is True
def test_strict_mention_string_off():
adapter = _make_adapter(strict_mention="off")
assert adapter._slack_strict_mention() is False
def test_strict_mention_malformed_stays_false():
"""Unrecognised values keep strict mode OFF (fail-open to legacy behavior)."""
adapter = _make_adapter(strict_mention="maybe")
assert adapter._slack_strict_mention() is False
def test_strict_mention_env_var_fallback(monkeypatch):
monkeypatch.setenv("SLACK_STRICT_MENTION", "true")
adapter = _make_adapter() # no config value -> falls back to env
assert adapter._slack_strict_mention() is True
# ---------------------------------------------------------------------------
# Tests: _slack_free_response_channels
# ---------------------------------------------------------------------------
@@ -310,3 +354,109 @@ def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
import os as _os
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
def test_config_bridges_slack_reply_in_thread(monkeypatch, tmp_path):
from gateway.config import load_gateway_config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" reply_in_thread: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
config = load_gateway_config()
assert config is not None
slack_config = config.platforms[Platform.SLACK]
assert slack_config.extra.get("reply_in_thread") is False
adapter = SlackAdapter(slack_config)
assert adapter._resolve_thread_ts(reply_to="171.000", metadata={}) is None
# Top-level channel messages arrive with metadata.thread_id == reply_to
# because the inbound handler uses event.ts as a session-keying fallback.
# Those must be treated as non-threaded so reply_in_thread=false takes
# effect in channels, not just DMs.
assert adapter._resolve_thread_ts(
reply_to="171.000",
metadata={"thread_id": "171.000"},
) is None
# Real thread replies (reply_to differs from thread parent) must still
# resolve to the parent thread so conversation context is preserved.
assert adapter._resolve_thread_ts(
reply_to="171.500",
metadata={"thread_id": "171.000"},
) == "171.000"
def test_config_bridges_slack_strict_mention(monkeypatch, tmp_path):
from gateway.config import load_gateway_config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" strict_mention: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False)
config = load_gateway_config()
assert config is not None
import os as _os
assert _os.environ["SLACK_STRICT_MENTION"] == "true"
# ---------------------------------------------------------------------------
# Regression: strict mode must NOT persist mentions into _mentioned_threads
# ---------------------------------------------------------------------------
# Prevents agent-to-agent ack loops — if a strict-mode bot remembered every
# thread it was mentioned in, the next message from the other agent in that
# thread would re-trigger the bot and defeat the entire feature.
def test_mention_in_strict_mode_does_not_register_thread():
adapter = _make_adapter(strict_mention=True)
adapter._bot_user_id = "U_BOT"
adapter._mentioned_threads = set()
adapter._MENTIONED_THREADS_MAX = 5000
thread_ts = "1700000000.100200"
event_thread_ts = thread_ts # incoming message is inside an existing thread
# Mirror the handler's @mention + strict-mode guard that protects
# _mentioned_threads.add(). If strict is on, we must skip the add.
text = "<@U_BOT> hello"
is_mentioned = f"<@{adapter._bot_user_id}>" in text
assert is_mentioned
if event_thread_ts and not adapter._slack_strict_mention():
adapter._mentioned_threads.add(event_thread_ts)
assert thread_ts not in adapter._mentioned_threads
def test_mention_outside_strict_mode_still_registers_thread():
adapter = _make_adapter(strict_mention=False)
adapter._bot_user_id = "U_BOT"
adapter._mentioned_threads = set()
adapter._MENTIONED_THREADS_MAX = 5000
thread_ts = "1700000000.100200"
event_thread_ts = thread_ts
text = "<@U_BOT> hello"
is_mentioned = f"<@{adapter._bot_user_id}>" in text
assert is_mentioned
if event_thread_ts and not adapter._slack_strict_mention():
adapter._mentioned_threads.add(event_thread_ts)
assert thread_ts in adapter._mentioned_threads
+94 -7
View File
@@ -12,9 +12,9 @@ from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
def _make_source() -> SessionSource:
def _make_source(platform: Platform = Platform.TELEGRAM) -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
platform=platform,
user_id="u1",
chat_id="c1",
user_name="tester",
@@ -22,24 +22,24 @@ def _make_source() -> SessionSource:
)
def _make_event(text: str) -> MessageEvent:
def _make_event(text: str, *, platform: Platform = Platform.TELEGRAM) -> MessageEvent:
return MessageEvent(
text=text,
source=_make_source(),
source=_make_source(platform),
message_id="m1",
)
def _make_runner(session_entry: SessionEntry):
def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.TELEGRAM):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
platforms={platform: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner.adapters = {platform: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
@@ -224,6 +224,93 @@ async def test_handle_message_persists_agent_token_counts(monkeypatch):
)
@pytest.mark.asyncio
async def test_first_run_slack_home_channel_onboarding_uses_parent_command(monkeypatch):
import gateway.run as gateway_run
session_entry = SessionEntry(
session_key=build_session_key(_make_source(Platform.SLACK)),
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.SLACK,
chat_type="dm",
)
runner = _make_runner(session_entry, platform=Platform.SLACK)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = False
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
"input_tokens": 0,
"output_tokens": 0,
"model": "openai/test-model",
}
)
monkeypatch.delenv("SLACK_HOME_CHANNEL", raising=False)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100000,
)
result = await runner._handle_message(_make_event("hello", platform=Platform.SLACK))
assert result == "ok"
runner.adapters[Platform.SLACK].send.assert_awaited_once()
onboarding = runner.adapters[Platform.SLACK].send.await_args.args[1]
assert "/hermes sethome" in onboarding
assert "Type /sethome" not in onboarding
@pytest.mark.asyncio
async def test_first_run_non_slack_home_channel_onboarding_keeps_direct_command(monkeypatch):
import gateway.run as gateway_run
session_entry = SessionEntry(
session_key=build_session_key(_make_source(Platform.TELEGRAM)),
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner = _make_runner(session_entry, platform=Platform.TELEGRAM)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = False
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
"input_tokens": 0,
"output_tokens": 0,
"model": "openai/test-model",
}
)
monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100000,
)
result = await runner._handle_message(_make_event("hello", platform=Platform.TELEGRAM))
assert result == "ok"
runner.adapters[Platform.TELEGRAM].send.assert_awaited_once()
onboarding = runner.adapters[Platform.TELEGRAM].send.await_args.args[1]
assert "Type /sethome" in onboarding
@pytest.mark.asyncio
async def test_handle_message_discards_stale_result_after_session_invalidation(monkeypatch):
import gateway.run as gateway_run
+111
View File
@@ -20,6 +20,8 @@ from hermes_cli.commands import (
discord_skill_commands,
gateway_help_lines,
resolve_command,
slack_app_manifest,
slack_native_slashes,
slack_subcommand_map,
telegram_bot_commands,
telegram_menu_commands,
@@ -256,6 +258,115 @@ class TestSlackSubcommandMap:
assert cmd.name not in mapping
class TestSlackNativeSlashes:
"""Slack native slash command generation — used to register every
COMMAND_REGISTRY entry as a first-class Slack slash, matching Discord
and Telegram."""
def test_returns_triples(self):
slashes = slack_native_slashes()
assert len(slashes) >= 10
for entry in slashes:
assert isinstance(entry, tuple) and len(entry) == 3
name, desc, hint = entry
assert isinstance(name, str) and name
assert isinstance(desc, str)
assert isinstance(hint, str)
def test_hermes_catchall_is_first(self):
"""``/hermes`` must be reserved as the first slot so the legacy
``/hermes <subcommand>`` form keeps working after we add new
commands and hit the 50-slash cap."""
slashes = slack_native_slashes()
assert slashes[0][0] == "hermes"
def test_names_respect_slack_limits(self):
for name, _desc, _hint in slack_native_slashes():
# Slack: lowercase a-z, 0-9, hyphens, underscores; max 32 chars
assert len(name) <= 32, f"slash {name!r} exceeds 32 chars"
assert name == name.lower()
for ch in name:
assert ch.isalnum() or ch in "-_", f"invalid char {ch!r} in {name!r}"
def test_under_fifty_command_cap(self):
"""Slack allows at most 50 slash commands per app."""
assert len(slack_native_slashes()) <= 50
def test_unique_names(self):
names = [n for n, _d, _h in slack_native_slashes()]
assert len(names) == len(set(names)), "duplicate Slack slash names"
def test_includes_canonical_commands(self):
names = {n for n, _d, _h in slack_native_slashes()}
# Sample of gateway-available canonical commands
for expected in ("new", "stop", "background", "model", "help", "status"):
assert expected in names, f"missing canonical /{expected}"
def test_includes_aliases_as_first_class_slashes(self):
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
slashes this is the whole point of native-slashes parity."""
names = {n for n, _d, _h in slack_native_slashes()}
assert "btw" in names
assert "bg" in names
assert "reset" in names
assert "q" in names
def test_telegram_parity(self):
"""Every Telegram bot command must be registerable on Slack too.
This catches the old behavior where Slack users couldn't invoke
commands like /btw natively. If a future command surfaces on
Telegram but not Slack (because of Slack's 50-slash cap), this
test fails loudly so we can curate the list rather than silently
dropping parity.
"""
slack_names = {n for n, _d, _h in slack_native_slashes()}
tg_names = {n for n, _d in telegram_bot_commands()}
# Some Telegram names have underscores where Slack uses hyphens
# (e.g. set_home vs sethome). Normalize both sides for comparison.
def _norm(s: str) -> str:
return s.replace("-", "_").replace("__", "_").strip("_")
slack_norm = {_norm(n) for n in slack_names}
tg_norm = {_norm(n) for n in tg_names}
missing = tg_norm - slack_norm
assert not missing, (
f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}"
)
class TestSlackAppManifest:
"""Generated Slack app manifest (used by `hermes slack manifest`)."""
def test_returns_dict(self):
m = slack_app_manifest()
assert isinstance(m, dict)
assert "features" in m
assert "slash_commands" in m["features"]
def test_each_slash_has_required_fields(self):
m = slack_app_manifest()
for entry in m["features"]["slash_commands"]:
assert entry["command"].startswith("/")
assert "description" in entry
assert "url" in entry
# should_escape must be present (Slack defaults to True which
# HTML-escapes args — we want the raw text)
assert "should_escape" in entry
def test_btw_is_in_manifest(self):
"""Regression: /btw must be a native Slack slash, not just a
/hermes subcommand."""
m = slack_app_manifest()
commands = [c["command"] for c in m["features"]["slash_commands"]]
assert "/btw" in commands
def test_custom_request_url(self):
m = slack_app_manifest(request_url="https://example.com/slack")
for entry in m["features"]["slash_commands"]:
assert entry["url"] == "https://example.com/slack"
# ---------------------------------------------------------------------------
# Config-gated gateway commands
# ---------------------------------------------------------------------------
+70 -2
View File
@@ -56,7 +56,7 @@ def three_source_env(monkeypatch, hub_env):
import tools.skills_tool as skills_tool
monkeypatch.setattr(hub, "HubLockFile", lambda: _DummyLockFile([_HUB_ENTRY]))
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: list(_ALL_THREE_SKILLS))
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: list(_ALL_THREE_SKILLS))
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: dict(_BUILTIN_MANIFEST))
return hub_env
@@ -107,7 +107,7 @@ def test_do_list_initializes_hub_dir(monkeypatch, hub_env):
import tools.skills_sync as skills_sync
import tools.skills_tool as skills_tool
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: [])
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: [])
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {})
hub_dir = hub_env
@@ -154,6 +154,74 @@ def test_do_list_filter_builtin(three_source_env):
assert "local-skill" not in output
def test_do_list_renders_status_column(three_source_env, monkeypatch):
"""Every list row should carry an enabled/disabled status (new in PR that
answered Mr Mochizuki's 'I just want to see what's live' question)."""
from agent import skill_utils
monkeypatch.setattr(skill_utils, "get_disabled_skill_names", lambda platform=None: set())
output = _capture()
assert "Status" in output
assert "enabled" in output.lower()
# Summary counts enabled skills.
assert "3 enabled, 0 disabled" in output
def test_do_list_marks_disabled_skills(three_source_env, monkeypatch):
from agent import skill_utils
# Simulate `skills.disabled: [hub-skill]` in config.
monkeypatch.setattr(
skill_utils, "get_disabled_skill_names",
lambda platform=None: {"hub-skill"},
)
output = _capture()
# Row still appears (no --enabled-only), but marked disabled
assert "hub-skill" in output
assert "disabled" in output.lower()
assert "2 enabled, 1 disabled" in output
def test_do_list_enabled_only_hides_disabled(three_source_env, monkeypatch):
from agent import skill_utils
monkeypatch.setattr(
skill_utils, "get_disabled_skill_names",
lambda platform=None: {"hub-skill"},
)
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_list(enabled_only=True, console=console)
output = sink.getvalue()
assert "hub-skill" not in output
assert "builtin-skill" in output
assert "local-skill" in output
assert "enabled only" in output.lower()
assert "2 enabled shown" in output
def test_do_list_platform_env_is_ignored(three_source_env, monkeypatch):
"""`hermes skills list` reads the active profile's config via
HERMES_HOME (swapped by -p), so it must NOT pass a platform arg to
``get_disabled_skill_names`` otherwise per-platform overrides
would silently leak in from HERMES_PLATFORM env."""
from agent import skill_utils
seen = {}
def _fake(platform=None):
seen["platform"] = platform
return set()
monkeypatch.setattr(skill_utils, "get_disabled_skill_names", _fake)
_capture()
assert seen["platform"] is None
def test_do_check_reports_available_updates(monkeypatch):
output = _capture_check(monkeypatch, [
{"name": "hub-skill", "source": "skills.sh", "status": "update_available"},
+73
View File
@@ -0,0 +1,73 @@
"""Regression tests for background review agent cleanup."""
from __future__ import annotations
import run_agent as run_agent_module
from run_agent import AIAgent
def _bare_agent() -> AIAgent:
agent = object.__new__(AIAgent)
agent.model = "fake-model"
agent.platform = "telegram"
agent.provider = "openai"
agent.base_url = ""
agent.api_key = ""
agent.api_mode = ""
agent.session_id = "test-session"
agent._parent_session_id = ""
agent._credential_pool = None
agent._memory_store = object()
agent._memory_enabled = True
agent._user_profile_enabled = False
agent._MEMORY_REVIEW_PROMPT = "review memory"
agent._SKILL_REVIEW_PROMPT = "review skills"
agent._COMBINED_REVIEW_PROMPT = "review both"
agent.background_review_callback = None
agent.status_callback = None
agent._safe_print = lambda *_args, **_kwargs: None
return agent
class ImmediateThread:
def __init__(self, *, target, daemon=None, name=None):
self._target = target
def start(self):
self._target()
def test_background_review_shuts_down_memory_provider_before_close(monkeypatch):
events = []
class FakeReviewAgent:
def __init__(self, **kwargs):
events.append(("init", kwargs))
self._session_messages = []
def run_conversation(self, **kwargs):
events.append(("run_conversation", kwargs))
def shutdown_memory_provider(self):
events.append(("shutdown_memory_provider", None))
def close(self):
events.append(("close", None))
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
agent = _bare_agent()
AIAgent._spawn_background_review(
agent,
messages_snapshot=[{"role": "user", "content": "hello"}],
review_memory=True,
)
assert [name for name, _payload in events] == [
"init",
"run_conversation",
"shutdown_memory_provider",
"close",
]
+42
View File
@@ -1485,6 +1485,48 @@ class TestListSessionsRich:
assert "\n" not in sessions[0]["preview"]
assert "Line one Line two" in sessions[0]["preview"]
def test_branch_session_visible_in_list(self, db):
"""Branch sessions (parent ended with 'branched') must appear in list_sessions_rich."""
db.create_session("parent", "cli")
db.end_session("parent", "branched")
db.create_session("branch", "cli", parent_session_id="parent")
db.append_message("branch", "user", "Exploring the alternative approach")
sessions = db.list_sessions_rich()
ids = [s["id"] for s in sessions]
assert "branch" in ids, "Branch session should be visible in default list"
def test_subagent_session_still_hidden(self, db):
"""Sub-agent children (parent NOT ended with 'branched') remain hidden."""
db.create_session("root", "cli")
db.create_session("delegate", "cli", parent_session_id="root")
sessions = db.list_sessions_rich()
ids = [s["id"] for s in sessions]
assert "delegate" not in ids, "Delegate sub-agent should not appear in default list"
assert "root" in ids
def test_compression_child_still_hidden(self, db):
"""Compression continuation sessions remain hidden (parent ended with 'compression')."""
import time as _time
t0 = _time.time()
db.create_session("root", "cli")
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root"))
db._conn.execute(
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
(t0 + 1800, "root"),
)
db._conn.commit()
db.create_session("continuation", "cli", parent_session_id="root")
db._conn.execute(
"UPDATE sessions SET started_at=? WHERE id=?", (t0 + 1801, "continuation")
)
db._conn.commit()
sessions = db.list_sessions_rich(project_compression_tips=False)
ids = [s["id"] for s in sessions]
assert "continuation" not in ids, "Compression continuation should stay hidden"
class TestCompressionChainProjection:
"""Tests for lineage-aware list_sessions_rich — compressed conversations
+109
View File
@@ -1807,3 +1807,112 @@ def test_model_options_propagates_list_exception(monkeypatch):
assert "error" in resp
assert resp["error"]["code"] == 5033
assert "catalog blew up" in resp["error"]["message"]
# ---------------------------------------------------------------------------
# prompt.submit — auto-title
# ---------------------------------------------------------------------------
class _ImmediateThread:
"""Runs the target callable synchronously so assertions can follow."""
def __init__(self, target=None, daemon=None):
self._target = target
def start(self):
self._target()
def test_prompt_submit_auto_titles_session_on_complete(monkeypatch):
"""maybe_auto_title is called after a successful (complete) prompt."""
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
return {
"final_response": "Rome was founded in 753 BC.",
"messages": [
{"role": "user", "content": "Tell me about Rome"},
{"role": "assistant", "content": "Rome was founded in 753 BC."},
],
}
server._sessions["sid"] = _session(agent=_Agent())
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
monkeypatch.setattr(server, "_get_db", lambda: None)
with patch("agent.title_generator.maybe_auto_title") as mock_title:
server.handle_request(
{
"id": "1",
"method": "prompt.submit",
"params": {"session_id": "sid", "text": "Tell me about Rome"},
}
)
mock_title.assert_called_once()
args = mock_title.call_args.args
assert args[1] == "session-key"
assert args[2] == "Tell me about Rome"
assert args[3] == "Rome was founded in 753 BC."
def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch):
"""maybe_auto_title must NOT be called when the agent was interrupted."""
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
return {
"final_response": "partial answer",
"interrupted": True,
"messages": [],
}
server._sessions["sid"] = _session(agent=_Agent())
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
monkeypatch.setattr(server, "_get_db", lambda: None)
with patch("agent.title_generator.maybe_auto_title") as mock_title:
server.handle_request(
{
"id": "1",
"method": "prompt.submit",
"params": {"session_id": "sid", "text": "Tell me about Rome"},
}
)
mock_title.assert_not_called()
def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
"""maybe_auto_title must NOT be called when the agent returns an empty reply."""
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
return {
"final_response": "",
"messages": [],
}
server._sessions["sid"] = _session(agent=_Agent())
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
monkeypatch.setattr(server, "_get_db", lambda: None)
with patch("agent.title_generator.maybe_auto_title") as mock_title:
server.handle_request(
{
"id": "1",
"method": "prompt.submit",
"params": {"session_id": "sid", "text": "Tell me about Rome"},
}
)
mock_title.assert_not_called()
+248
View File
@@ -0,0 +1,248 @@
"""Tests for hybrid browser-backend routing (LAN/localhost auto-local).
When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is
configured globally, ``browser.auto_local_for_private_urls`` (default True)
causes ``browser_navigate`` to transparently spawn a local Chromium sidecar
for URLs whose host resolves to a private/loopback/LAN address, while
public URLs continue to hit the cloud session in the same conversation.
These tests cover the routing decision layer session_key selection,
sidecar detection, last-active-session tracking, and the config toggle.
The downstream session creation is covered by test_browser_cloud_fallback.py.
"""
from unittest.mock import Mock
import pytest
import tools.browser_tool as browser_tool
@pytest.fixture(autouse=True)
def _reset_routing_state(monkeypatch):
"""Clear module-level caches so each test starts clean."""
monkeypatch.setattr(browser_tool, "_active_sessions", {})
monkeypatch.setattr(browser_tool, "_last_active_session_key", {})
monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None)
monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False)
monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls_resolved", False)
monkeypatch.setattr(browser_tool, "_cached_auto_local_for_private_urls", True)
monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None)
# Default: no CDP override, no Camofox
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
class TestNavigationSessionKey:
"""Tests for _navigation_session_key URL-based routing decisions."""
def test_public_url_uses_bare_task_id(self, monkeypatch):
"""Public URL with cloud provider configured → bare task_id (cloud)."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "https://github.com/x/y")
assert key == "default"
def test_localhost_routes_to_local_sidecar(self, monkeypatch):
"""``localhost`` URL → ``::local`` suffix when cloud configured + flag on."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default::local"
def test_loopback_ipv4_routes_to_local_sidecar(self, monkeypatch):
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "http://127.0.0.1:8080/")
assert key == "default::local"
def test_rfc1918_lan_routes_to_local_sidecar(self, monkeypatch):
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "http://192.168.1.50:8000/")
assert key == "default::local"
def test_ipv6_loopback_routes_to_local_sidecar(self, monkeypatch):
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "http://[::1]:3000/")
assert key == "default::local"
def test_public_ip_literal_uses_bare_task_id(self, monkeypatch):
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key("default", "https://8.8.8.8/")
assert key == "default"
def test_mdns_local_hostname_routes_to_sidecar(self, monkeypatch):
"""``*.local`` mDNS / ``*.lan`` / ``*.internal`` hostnames route to sidecar."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
for host in ("raspberrypi.local", "printer.lan", "db.internal"):
key = browser_tool._navigation_session_key("default", f"http://{host}/")
assert key == "default::local", f"host {host!r} did not route to sidecar"
def test_no_cloud_provider_stays_on_bare_task_id(self, monkeypatch):
"""When cloud provider is not configured, no hybrid routing happens."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None)
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default"
def test_camofox_mode_stays_on_bare_task_id(self, monkeypatch):
"""Camofox is already local — no hybrid routing needed."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True)
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default"
def test_cdp_override_stays_on_bare_task_id(self, monkeypatch):
"""A user-supplied CDP endpoint owns the whole session — no hybrid."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://localhost:9222")
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default"
def test_feature_flag_off_disables_hybrid_routing(self, monkeypatch):
"""``auto_local_for_private_urls: false`` keeps private URLs on cloud."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls", lambda: False)
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default"
def test_none_task_id_defaults(self, monkeypatch):
"""``None`` task_id resolves to 'default'."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
key = browser_tool._navigation_session_key(None, "http://localhost:3000/")
assert key == "default::local"
class TestSessionKeyHelpers:
def test_is_local_sidecar_key(self):
assert browser_tool._is_local_sidecar_key("default::local")
assert browser_tool._is_local_sidecar_key("my_task::local")
assert not browser_tool._is_local_sidecar_key("default")
assert not browser_tool._is_local_sidecar_key("my_task")
def test_last_session_key_falls_back_to_task_id(self, monkeypatch):
"""Without a recorded last-active key, returns the bare task_id."""
monkeypatch.setattr(browser_tool, "_last_active_session_key", {})
assert browser_tool._last_session_key("default") == "default"
assert browser_tool._last_session_key("task-42") == "task-42"
assert browser_tool._last_session_key(None) == "default"
def test_last_session_key_returns_recorded_key(self, monkeypatch):
monkeypatch.setattr(
browser_tool,
"_last_active_session_key",
{"default": "default::local", "task-42": "task-42"},
)
assert browser_tool._last_session_key("default") == "default::local"
assert browser_tool._last_session_key("task-42") == "task-42"
# Unknown task_id still falls back
assert browser_tool._last_session_key("other") == "other"
class TestHybridRoutingSessionCreation:
"""_get_session_info must force a local session when the key carries ``::local``."""
def test_local_sidecar_key_skips_cloud_provider(self, monkeypatch):
"""A ``::local``-suffixed key creates a local session even when cloud is set."""
provider = Mock()
provider.create_session.return_value = {
"session_name": "should_not_be_used",
"bb_session_id": "bb_xxx",
"cdp_url": "wss://fake.browserbase.com/ws",
}
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None)
session = browser_tool._get_session_info("default::local")
assert provider.create_session.call_count == 0
assert session["bb_session_id"] is None
assert session["cdp_url"] is None
assert session["features"]["local"] is True
def test_bare_task_id_with_cloud_provider_uses_cloud(self, monkeypatch):
"""A bare task_id with cloud provider configured hits the cloud path."""
provider = Mock()
provider.create_session.return_value = {
"session_name": "cloud-sess",
"bb_session_id": "bb_123",
"cdp_url": "wss://real.browserbase.com/ws",
}
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None)
monkeypatch.setattr(browser_tool, "_resolve_cdp_override", lambda u: u)
session = browser_tool._get_session_info("default")
assert provider.create_session.call_count == 1
assert session["bb_session_id"] == "bb_123"
class TestCleanupHybridSessions:
"""cleanup_browser(bare_task_id) must reap both cloud + local sidecar sessions."""
def test_cleanup_reaps_both_primary_and_sidecar(self, monkeypatch):
"""Given a bare task_id with both sessions alive, both get cleaned."""
reaped = []
def _fake_cleanup_one(key):
reaped.append(key)
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{
"default": {"session_name": "cloud_sess"},
"default::local": {"session_name": "local_sess"},
},
)
monkeypatch.setattr(
browser_tool, "_last_active_session_key", {"default": "default::local"}
)
browser_tool.cleanup_browser("default")
assert set(reaped) == {"default", "default::local"}
# last-active pointer dropped
assert "default" not in browser_tool._last_active_session_key
def test_cleanup_reaps_only_primary_when_no_sidecar(self, monkeypatch):
"""When no sidecar exists, only the primary is reaped."""
reaped = []
def _fake_cleanup_one(key):
reaped.append(key)
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{"default": {"session_name": "cloud_sess"}},
)
browser_tool.cleanup_browser("default")
assert reaped == ["default"]
def test_cleanup_sidecar_directly_keeps_primary(self, monkeypatch):
"""Calling cleanup with a ``::local`` key reaps only the sidecar."""
reaped = []
def _fake_cleanup_one(key):
reaped.append(key)
monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{
"default": {"session_name": "cloud_sess"},
"default::local": {"session_name": "local_sess"},
},
)
monkeypatch.setattr(
browser_tool, "_last_active_session_key", {"default": "default::local"}
)
browser_tool.cleanup_browser("default::local")
assert reaped == ["default::local"]
# Last-active pointer NOT dropped (primary task is still alive)
assert browser_tool._last_active_session_key.get("default") == "default::local"
@@ -0,0 +1,210 @@
"""Tests for credential_pool .env fallback and auth credential_pool lookup.
Covers the fix from #15914 / PR #15920:
- _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ
- _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty
- env vars take priority over .env file (handled by get_env_value itself)
- env vars take priority over credential pool (fallback only kicks in when env is empty)
"""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def _make_pconfig(provider_id="deepseek", env_vars=None):
"""Create a minimal ProviderConfig for testing.
Default provider_id is 'deepseek' because it's a real api_key provider
in PROVIDER_REGISTRY (needed for _seed_from_env's generic path).
"""
from hermes_cli.auth import ProviderConfig
return ProviderConfig(
id=provider_id,
name=provider_id.title(),
auth_type="api_key",
api_key_env_vars=tuple(env_vars or [f"{provider_id.upper()}_API_KEY"]),
)
@pytest.fixture
def isolated_hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a temp dir and clear known API key env vars.
Also invalidates any cached get_env_value state by patching Path.home().
"""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
# Clear all known API key env vars so get_env_value falls through to .env
for key in [
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY",
"ZAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_BASE_URL",
]:
monkeypatch.delenv(key, raising=False)
return home
def _write_env_file(home: Path, **kwargs) -> None:
"""Write key=value pairs to ~/.hermes/.env."""
lines = [f"{k}={v}" for k, v in kwargs.items()]
(home / ".env").write_text("\n".join(lines) + "\n")
class TestCredentialPoolSeedsFromDotEnv:
"""_seed_from_env must read keys from ~/.hermes/.env, not just os.environ.
This is the load-bearing behaviour for the fix: when a user adds a key to
.env mid-session or via a non-CLI entry point that doesn't run
load_hermes_dotenv, the credential pool must still discover it.
"""
def test_deepseek_key_from_dotenv_only(self, isolated_hermes_home):
"""Key in .env but not os.environ → _seed_from_env adds a pool entry."""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-only-12345")
assert "DEEPSEEK_API_KEY" not in os.environ
from agent.credential_pool import _seed_from_env
entries = []
changed, active_sources = _seed_from_env("deepseek", entries)
assert changed is True
assert "env:DEEPSEEK_API_KEY" in active_sources
assert any(
e.access_token == "sk-dotenv-only-12345"
and e.source == "env:DEEPSEEK_API_KEY"
for e in entries
), f"Expected seeded entry with dotenv key, got: {[(e.source, e.access_token) for e in entries]}"
def test_openrouter_key_from_dotenv_only(self, isolated_hermes_home):
"""OpenRouter path has its own branch — verify it also reads .env."""
_write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-or-dotenv-abc")
assert "OPENROUTER_API_KEY" not in os.environ
from agent.credential_pool import _seed_from_env
entries = []
changed, active_sources = _seed_from_env("openrouter", entries)
assert changed is True
assert "env:OPENROUTER_API_KEY" in active_sources
assert any(
e.access_token == "sk-or-dotenv-abc" for e in entries
)
def test_empty_dotenv_no_entries(self, isolated_hermes_home):
"""No .env file, no env vars → no entries seeded (and no crash)."""
from agent.credential_pool import _seed_from_env
entries = []
changed, active_sources = _seed_from_env("deepseek", entries)
assert changed is False
assert active_sources == set()
assert entries == []
def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch):
"""get_env_value checks os.environ first — verify seeding picks that up."""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale")
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz")
from agent.credential_pool import _seed_from_env
entries = []
changed, _ = _seed_from_env("deepseek", entries)
assert changed is True
seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"]
assert len(seeded) == 1
assert seeded[0].access_token == "sk-env-fresh-xyz"
class TestAuthResolvesFromDotEnv:
"""_resolve_api_key_provider_secret must also read from ~/.hermes/.env."""
def test_key_from_dotenv_only(self, isolated_hermes_home):
"""Key in .env but not os.environ → _resolve returns it with the env var source."""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-resolve-789")
assert "DEEPSEEK_API_KEY" not in os.environ
from hermes_cli.auth import _resolve_api_key_provider_secret
key, source = _resolve_api_key_provider_secret(
provider_id="deepseek",
pconfig=_make_pconfig(),
)
assert key == "sk-dotenv-resolve-789"
assert source == "DEEPSEEK_API_KEY"
class TestAuthCredentialPoolFallback:
"""_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty."""
def test_credential_pool_fallback_structure(self, isolated_hermes_home):
"""Empty env + empty .env → auth falls back to credential pool."""
mock_entry = MagicMock()
mock_entry.access_token = "test-pool-key-12345"
mock_entry.runtime_api_key = ""
mock_pool = MagicMock()
mock_pool.has_credentials.return_value = True
mock_pool.peek.return_value = mock_entry
from hermes_cli.auth import _resolve_api_key_provider_secret
with patch("agent.credential_pool.load_pool", return_value=mock_pool):
key, source = _resolve_api_key_provider_secret(
provider_id="deepseek",
pconfig=_make_pconfig(),
)
assert "test-pool-key-12345" in key
assert "credential_pool" in source
def test_credential_pool_empty_returns_empty(self, isolated_hermes_home):
"""Empty env + empty .env + empty pool → empty string."""
mock_pool = MagicMock()
mock_pool.has_credentials.return_value = False
from hermes_cli.auth import _resolve_api_key_provider_secret
with patch("agent.credential_pool.load_pool", return_value=mock_pool):
key, source = _resolve_api_key_provider_secret(
provider_id="deepseek",
pconfig=_make_pconfig(),
)
assert key == ""
def test_env_var_takes_priority_over_pool(self, isolated_hermes_home, monkeypatch):
"""os.environ key wins — credential pool is NEVER consulted."""
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-key-first-abc123")
mock_pool = MagicMock()
mock_pool.has_credentials.return_value = True
from hermes_cli.auth import _resolve_api_key_provider_secret
with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp:
key, source = _resolve_api_key_provider_secret(
provider_id="deepseek",
pconfig=_make_pconfig(),
)
assert key == "sk-env-key-first-abc123"
assert source == "DEEPSEEK_API_KEY"
# Pool should not even have been loaded — env var satisfied the request first
mp.assert_not_called()
def test_dotenv_takes_priority_over_pool(self, isolated_hermes_home):
"""Key in .env beats credential pool — pool only fires when both env sources are empty."""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-priority-xyz")
assert "DEEPSEEK_API_KEY" not in os.environ
mock_pool = MagicMock()
mock_pool.has_credentials.return_value = True
from hermes_cli.auth import _resolve_api_key_provider_secret
with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp:
key, source = _resolve_api_key_provider_secret(
provider_id="deepseek",
pconfig=_make_pconfig(),
)
assert key == "sk-dotenv-priority-xyz"
assert source == "DEEPSEEK_API_KEY"
mp.assert_not_called()
+38
View File
@@ -810,6 +810,44 @@ class TestParseTargetRefE164:
assert _parse_target_ref("matrix", "+15551234567")[2] is False
class TestParseTargetRefSlack:
"""_parse_target_ref recognizes Slack channel/user IDs as explicit."""
def test_public_channel_id_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G")
assert chat_id == "C0B0QV5434G"
assert thread_id is None
assert is_explicit is True
def test_private_channel_id_is_explicit(self):
assert _parse_target_ref("slack", "G123ABCDEF")[2] is True
def test_dm_id_is_explicit(self):
assert _parse_target_ref("slack", "D123ABCDEF")[2] is True
def test_user_id_is_not_explicit(self):
"""Slack user IDs (U...) and workspace IDs (W...) are NOT explicit send
targets. chat.postMessage rejects them a DM must be opened first via
conversations.open to obtain a D... conversation ID.
"""
assert _parse_target_ref("slack", "U123ABCDEF")[2] is False
assert _parse_target_ref("slack", "W123ABCDEF")[2] is False
def test_whitespace_is_stripped(self):
chat_id, _, is_explicit = _parse_target_ref("slack", " C0B0QV5434G ")
assert chat_id == "C0B0QV5434G"
assert is_explicit is True
def test_lowercase_or_short_id_is_not_explicit(self):
assert _parse_target_ref("slack", "c0b0qv5434g")[2] is False
assert _parse_target_ref("slack", "C123")[2] is False
assert _parse_target_ref("slack", "X0B0QV5434G")[2] is False
def test_slack_id_not_explicit_for_other_platforms(self):
assert _parse_target_ref("discord", "C0B0QV5434G")[2] is False
assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False
class TestSendDiscordThreadId:
"""_send_discord uses thread_id when provided."""
@@ -0,0 +1,107 @@
"""
Regression tests for the shared-container task_id mapping.
The top-level agent and all delegate_task subagents share a single
terminal sandbox keyed by ``"default"``. ``_resolve_container_task_id``
is the sole gatekeeper for which tool-call task_ids go to the shared
container vs. get their own isolated sandbox. RL / benchmark
environments opt in to isolation by calling
``register_task_env_overrides(task_id, {...})`` before the agent loop;
every other task_id collapses back to ``"default"``.
If you change the collapse logic, update both the helper and these
tests -- see `hermes-agent-dev` skill, "Why do subagents get their own
containers?" section, and the Container lifecycle paragraph under
Docker Backend in ``website/docs/user-guide/configuration.md``.
"""
import pytest
from tools import terminal_tool
@pytest.fixture(autouse=True)
def _clean_overrides():
"""Ensure no stray overrides from other tests leak in."""
before = dict(terminal_tool._task_env_overrides)
terminal_tool._task_env_overrides.clear()
yield
terminal_tool._task_env_overrides.clear()
terminal_tool._task_env_overrides.update(before)
def test_none_task_id_maps_to_default():
assert terminal_tool._resolve_container_task_id(None) == "default"
def test_empty_task_id_maps_to_default():
assert terminal_tool._resolve_container_task_id("") == "default"
def test_literal_default_stays_default():
assert terminal_tool._resolve_container_task_id("default") == "default"
def test_subagent_task_id_collapses_to_default():
# delegate_task constructs IDs like "subagent-<N>-<uuid_hex>"; these
# should share the parent's container, not spin up their own.
assert terminal_tool._resolve_container_task_id("subagent-0-deadbeef") == "default"
assert terminal_tool._resolve_container_task_id("subagent-42-cafef00d") == "default"
def test_arbitrary_session_id_collapses_to_default():
# Session UUIDs or anything else without an override still collapse.
assert terminal_tool._resolve_container_task_id("sess-123e4567-e89b-12d3") == "default"
def test_rl_task_with_override_keeps_its_own_id():
# RL / benchmark pattern: register a per-task image, then the task_id
# must survive ``_resolve_container_task_id`` so the rollout lands in
# its own sandbox.
terminal_tool.register_task_env_overrides(
"tb2-task-fix-git", {"docker_image": "tb2:fix-git", "cwd": "/app"}
)
try:
assert (
terminal_tool._resolve_container_task_id("tb2-task-fix-git")
== "tb2-task-fix-git"
)
finally:
terminal_tool.clear_task_env_overrides("tb2-task-fix-git")
def test_cleared_override_collapses_again():
terminal_tool.register_task_env_overrides("tb2-x", {"docker_image": "x:y"})
assert terminal_tool._resolve_container_task_id("tb2-x") == "tb2-x"
terminal_tool.clear_task_env_overrides("tb2-x")
assert terminal_tool._resolve_container_task_id("tb2-x") == "default"
def test_get_active_env_reads_shared_container_from_subagent_id():
"""``get_active_env`` must see the shared ``"default"`` sandbox when
called with a subagent's task_id, so the agent loop's turn-budget
enforcement reads the real env (not None) during delegation."""
sentinel = object()
terminal_tool._active_environments["default"] = sentinel
try:
assert terminal_tool.get_active_env("subagent-7-cafe") is sentinel
assert terminal_tool.get_active_env(None) is sentinel
assert terminal_tool.get_active_env("default") is sentinel
finally:
terminal_tool._active_environments.pop("default", None)
def test_get_active_env_honours_rl_override():
rl_env = object()
default_env = object()
terminal_tool._active_environments["default"] = default_env
terminal_tool._active_environments["rl-42"] = rl_env
terminal_tool.register_task_env_overrides("rl-42", {"docker_image": "x"})
try:
# With an override registered, lookup returns the task's own env,
# not the shared "default" one.
assert terminal_tool.get_active_env("rl-42") is rl_env
finally:
terminal_tool.clear_task_env_overrides("rl-42")
terminal_tool._active_environments.pop("default", None)
terminal_tool._active_environments.pop("rl-42", None)
+280 -49
View File
@@ -483,6 +483,147 @@ def _is_local_backend() -> bool:
return _is_camofox_mode() or _get_cloud_provider() is None
_auto_local_for_private_urls_resolved = False
_cached_auto_local_for_private_urls: bool = True
def _auto_local_for_private_urls() -> bool:
"""Return whether a cloud-configured install should auto-spawn a local
Chromium for LAN/localhost URLs.
Reads ``browser.auto_local_for_private_urls`` once (default ``True``) and
caches it for the process lifetime. When enabled, ``browser_navigate``
routes URLs whose host resolves to a private/loopback/LAN address to a
local headless Chromium sidecar even when a cloud provider (Browserbase
/ Browser-Use / Firecrawl) is configured globally. Public URLs continue
to use the cloud provider in the same conversation.
"""
global _auto_local_for_private_urls_resolved, _cached_auto_local_for_private_urls
if _auto_local_for_private_urls_resolved:
return _cached_auto_local_for_private_urls
_auto_local_for_private_urls_resolved = True
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {})
if isinstance(browser_cfg, dict) and "auto_local_for_private_urls" in browser_cfg:
_cached_auto_local_for_private_urls = bool(
browser_cfg.get("auto_local_for_private_urls")
)
except Exception as e:
logger.debug("Could not read auto_local_for_private_urls from config: %s", e)
return _cached_auto_local_for_private_urls
def _url_is_private(url: str) -> bool:
"""Return True when the URL's host resolves to a private/LAN/loopback address.
Reuses ``tools.url_safety.is_safe_url`` as the oracle if the SSRF check
would reject the URL, we treat it as "private" for routing purposes. DNS
resolution failures are treated as NOT private (fall through to whatever
backend is configured, which will surface the DNS error naturally).
"""
try:
from tools.url_safety import is_safe_url
# is_safe_url returns False for private/loopback/link-local/CGNAT AND
# for DNS failures. We only want the private-network case here, so
# we parse + check the host shape as a DNS-failure sieve first.
from urllib.parse import urlparse
import ipaddress
import socket
parsed = urlparse(url)
hostname = (parsed.hostname or "").strip().lower().rstrip(".")
if not hostname:
return False
# Literal IP → check directly
try:
ip = ipaddress.ip_address(hostname)
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip in ipaddress.ip_network("100.64.0.0/10")
)
except ValueError:
pass
# Hostname — must resolve to confirm it's private (bare "localhost"
# resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious
# names to avoid a DNS hop.
if hostname in ("localhost",) or hostname.endswith(".localhost"):
return True
if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"):
return True
try:
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
return False # DNS fail → not private, let the normal path fail
for _, _, _, _, sockaddr in addr_info:
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError:
continue
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip in ipaddress.ip_network("100.64.0.0/10")
):
return True
return False
except Exception as exc:
logger.debug("URL-privacy check failed for %s: %s", url, exc)
return False
def _navigation_session_key(task_id: str, url: str) -> str:
"""Pick the session key that should handle ``url`` for ``task_id``.
Returns the bare task_id unless ALL of these are true:
1. A cloud provider is configured (``_get_cloud_provider()`` is not None).
2. Auto-local routing is enabled (``browser.auto_local_for_private_urls``,
default True).
3. The URL resolves to a private/LAN/loopback address.
4. A CDP override is not active (that path owns the whole session).
5. Camofox mode is not active (Camofox is already local-only).
When all are true, returns ``f"{task_id}::local"`` so the hybrid-routing
path spawns a local Chromium sidecar while the cloud session (if any)
continues to serve public URLs.
"""
if task_id is None:
task_id = "default"
if _get_cdp_override():
return task_id
if _is_camofox_mode():
return task_id
if _get_cloud_provider() is None:
return task_id
if not _auto_local_for_private_urls():
return task_id
if not _url_is_private(url):
return task_id
return f"{task_id}{_LOCAL_SUFFIX}"
def _is_local_sidecar_key(session_key: str) -> bool:
"""Return True when ``session_key`` is a hybrid-routing local sidecar."""
return session_key.endswith(_LOCAL_SUFFIX)
def _last_session_key(task_id: str) -> str:
"""Return the session key to use for a non-nav browser tool call.
If a previous ``browser_navigate`` on this task_id set a last-active key,
use it so snapshot/click/fill/etc. hit the same session. Otherwise fall
back to the bare task_id (matches original behavior for tasks that never
triggered hybrid routing).
"""
if task_id is None:
task_id = "default"
return _last_active_session_key.get(task_id, task_id)
def _allow_private_urls() -> bool:
"""Return whether the browser is allowed to navigate to private/internal addresses.
@@ -521,10 +662,25 @@ def _socket_safe_tmpdir() -> str:
return tempfile.gettempdir()
# Track active sessions per task
# Track active sessions per "session key".
#
# A "session key" is either the bare task_id (cloud/default path) OR a composite
# like f"{task_id}::local" when the hybrid-routing feature spawns a local sidecar
# browser for a LAN/localhost URL while a cloud provider is configured globally.
# Both forms flow through the same _active_sessions / _run_browser_command /
# cleanup_browser code paths — the key is opaque to those internals.
#
# Stores: session_name (always), bb_session_id + cdp_url (cloud mode only)
_active_sessions: Dict[str, Dict[str, str]] = {} # task_id -> {session_name, ...}
_recording_sessions: set = set() # task_ids with active recordings
_active_sessions: Dict[str, Dict[str, str]] = {} # session_key -> {session_name, ...}
_recording_sessions: set = set() # session_keys with active recordings
# Tracks the most recent session_key used per task_id. Set by browser_navigate()
# after it chooses a backend for a URL; read by every non-nav browser tool
# (snapshot/click/fill/eval/...) so they target the session that served the last
# navigation. Without this, a task that navigated to localhost on the local
# sidecar would fall back to the cloud session on its next snapshot call.
_last_active_session_key: Dict[str, str] = {} # task_id -> session_key
_LOCAL_SUFFIX = "::local"
# Flag to track if cleanup has been done
_cleanup_done = False
@@ -1014,37 +1170,48 @@ def _create_cdp_session(task_id: str, cdp_url: str) -> Dict[str, str]:
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
"""
Get or create session info for the given task.
Get or create session info for the given session key.
In cloud mode, creates a Browserbase session with proxies enabled.
In local mode, generates a session name for agent-browser --session.
Also starts the inactivity cleanup thread and updates activity tracking.
Thread-safe: multiple subagents can call this concurrently.
Args:
task_id: Unique identifier for the task
task_id: Session key. Normally the task_id as-is, but may carry the
``::local`` suffix for the hybrid-routing local sidecar in that
case the cloud provider is skipped even when one is configured,
and a local Chromium session is created instead.
Returns:
Dict with session_name (always), bb_session_id + cdp_url (cloud only)
"""
if task_id is None:
task_id = "default"
# Start the cleanup thread if not running (handles inactivity timeouts)
_start_browser_cleanup_thread()
# Update activity timestamp for this session
_update_session_activity(task_id)
with _cleanup_lock:
# Check if we already have a session for this task
if task_id in _active_sessions:
return _active_sessions[task_id]
# Hybrid routing: session keys ending with ``::local`` force a local
# Chromium regardless of the globally-configured cloud provider. Public
# URLs in the same conversation continue to use the cloud session under
# the bare task_id key.
force_local = _is_local_sidecar_key(task_id)
# Create session outside the lock (network call in cloud mode)
cdp_override = _get_cdp_override()
if cdp_override:
if cdp_override and not force_local:
session_info = _create_cdp_session(task_id, cdp_override)
elif force_local:
session_info = _create_local_session(task_id)
else:
provider = _get_cloud_provider()
if provider is None:
@@ -1081,7 +1248,7 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
session_info["fallback_from_cloud"] = True
session_info["fallback_reason"] = str(e)
session_info["fallback_provider"] = provider_name
with _cleanup_lock:
# Double-check: another thread may have created a session while we
# were doing the network call. Use the existing one to avoid leaking
@@ -1093,7 +1260,9 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
# Lazy-start the CDP supervisor now that the session exists (if the
# backend surfaces a CDP URL via override or session_info["cdp_url"]).
# Idempotent; swallows errors. See _ensure_cdp_supervisor for details.
_ensure_cdp_supervisor(task_id)
# Skip for local sidecars — they have no CDP URL.
if not force_local:
_ensure_cdp_supervisor(task_id)
return session_info
@@ -1521,9 +1690,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# SSRF protection — block private/internal addresses before navigating.
# Skipped for local backends (Camofox, headless Chromium without a cloud
# provider) because the agent already has full local network access via
# the terminal tool. Can also be opted out for cloud mode via
# ``browser.allow_private_urls`` in config.
if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url):
# the terminal tool. Also skipped when hybrid routing will auto-spawn a
# local Chromium sidecar for this URL (cloud provider configured +
# private URL + ``browser.auto_local_for_private_urls`` enabled) — the
# cloud provider never sees the URL in that case. Can also be opted
# out globally via ``browser.allow_private_urls`` in config.
effective_task_id = task_id or "default"
nav_session_key = _navigation_session_key(effective_task_id, url)
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
if (
not _is_local_backend()
and not auto_local_this_nav
and not _allow_private_urls()
and not _is_safe_url(url)
):
return json.dumps({
"success": False,
"error": "Blocked: URL targets a private or internal address",
@@ -1543,19 +1724,31 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_navigate
return camofox_navigate(url, task_id)
effective_task_id = task_id or "default"
if auto_local_this_nav:
logger.info(
"browser_navigate: auto-routing %s to local Chromium sidecar "
"(cloud provider %s stays on cloud for public URLs; "
"set browser.auto_local_for_private_urls: false to disable)",
url,
type(_get_cloud_provider()).__name__ if _get_cloud_provider() else "none",
)
# Get session info to check if this is a new session
# (will create one with features logged if not exists)
session_info = _get_session_info(effective_task_id)
session_info = _get_session_info(nav_session_key)
is_first_nav = session_info.get("_first_nav", True)
# Auto-start recording if configured and this is first navigation
if is_first_nav:
session_info["_first_nav"] = False
_maybe_start_recording(effective_task_id)
_maybe_start_recording(nav_session_key)
result = _run_browser_command(effective_task_id, "open", [url], timeout=max(_get_command_timeout(), 60))
result = _run_browser_command(nav_session_key, "open", [url], timeout=max(_get_command_timeout(), 60))
# Remember which session served this nav so snapshot/click/fill/...
# on the same task_id hit it (critical when hybrid routing has both a
# cloud session and a local sidecar alive concurrently).
_last_active_session_key[effective_task_id] = nav_session_key
if result.get("success"):
data = result.get("data", {})
@@ -1565,10 +1758,17 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# Post-redirect SSRF check — if the browser followed a redirect to a
# private/internal address, block the result so the model can't read
# internal content via subsequent browser_snapshot calls.
# Skipped for local backends (same rationale as the pre-nav check).
if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url):
# Skipped for local backends (same rationale as the pre-nav check),
# and for the hybrid local sidecar (we're already on a local browser
# hitting a private URL by design).
if (
not _is_local_backend()
and not auto_local_this_nav
and not _allow_private_urls()
and final_url and final_url != url and not _is_safe_url(final_url)
):
# Navigate away to a blank page to prevent snapshot leaks
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
_run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
return json.dumps({
"success": False,
"error": "Blocked: redirect landed on a private/internal address",
@@ -1612,7 +1812,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# Auto-take a compact snapshot so the model can act immediately
# without a separate browser_snapshot call.
try:
snap_result = _run_browser_command(effective_task_id, "snapshot", ["-c"])
snap_result = _run_browser_command(nav_session_key, "snapshot", ["-c"])
if snap_result.get("success"):
snap_data = snap_result.get("data", {})
snapshot_text = snap_data.get("snapshot", "")
@@ -1652,7 +1852,7 @@ def browser_snapshot(
from tools.browser_camofox import camofox_snapshot
return camofox_snapshot(full, task_id, user_task)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
# Build command args based on full flag
args = []
@@ -1714,7 +1914,7 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_click
return camofox_click(ref, task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
# Ensure ref starts with @
if not ref.startswith("@"):
@@ -1750,7 +1950,7 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_type
return camofox_type(ref, text, task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
# Ensure ref starts with @
if not ref.startswith("@"):
@@ -1804,7 +2004,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
result = camofox_scroll(direction, task_id)
return result
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
result = _run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)])
if not result.get("success"):
@@ -1833,7 +2033,7 @@ def browser_back(task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_back
return camofox_back(task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
result = _run_browser_command(effective_task_id, "back", [])
if result.get("success"):
@@ -1864,7 +2064,7 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_press
return camofox_press(key, task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
result = _run_browser_command(effective_task_id, "press", [key])
if result.get("success"):
@@ -1906,7 +2106,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
from tools.browser_camofox import camofox_console
return camofox_console(clear, task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
console_args = ["--clear"] if clear else []
error_args = ["--clear"] if clear else []
@@ -1945,7 +2145,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
if _is_camofox_mode():
return _camofox_eval(expression, task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
result = _run_browser_command(effective_task_id, "eval", [expression])
if not result.get("success"):
@@ -2077,7 +2277,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
from tools.browser_camofox import camofox_get_images
return camofox_get_images(task_id)
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
# Use eval to run JavaScript that extracts images
js_code = """JSON.stringify(
@@ -2147,7 +2347,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
import base64
import uuid as uuid_mod
effective_task_id = task_id or "default"
effective_task_id = _last_session_key(task_id or "default")
# Save screenshot to persistent location so it can be shared with users
from hermes_constants import get_hermes_dir
@@ -2350,17 +2550,47 @@ def _cleanup_old_recordings(max_age_hours=72):
def cleanup_browser(task_id: Optional[str] = None) -> None:
"""
Clean up browser session for a task.
Clean up browser session(s) for a task.
Called automatically when a task completes or when inactivity timeout is reached.
Closes both the agent-browser/Browserbase session and Camofox sessions.
When ``task_id`` is a bare task identifier (no ``::local`` suffix), reaps
BOTH the cloud/primary session AND any hybrid-routing local sidecar that
may have been spawned for LAN/localhost URLs in the same task. When
``task_id`` already carries a ``::local`` suffix (called from the inactivity
cleanup loop against a specific session key), reaps only that one.
Args:
task_id: Task identifier to clean up
task_id: Task identifier (or explicit session key)
"""
if task_id is None:
task_id = "default"
# Expand to the full set of session keys to reap. For a bare task_id
# that includes the cloud/primary key + the local sidecar if one exists.
if _is_local_sidecar_key(task_id):
session_keys = [task_id]
bare_task_id = task_id[: -len(_LOCAL_SUFFIX)]
else:
session_keys = [task_id]
sidecar_key = f"{task_id}{_LOCAL_SUFFIX}"
with _cleanup_lock:
if sidecar_key in _active_sessions:
session_keys.append(sidecar_key)
bare_task_id = task_id
for session_key in session_keys:
_cleanup_single_browser_session(session_key)
# Drop the last-active pointer only when the bare task is being cleaned
# (i.e. not when we're only reaping a sidecar mid-task).
if not _is_local_sidecar_key(task_id):
_last_active_session_key.pop(bare_task_id, None)
def _cleanup_single_browser_session(task_id: str) -> None:
"""Internal: reap a single browser session by its exact session key."""
# Stop the CDP supervisor for this task FIRST so we close our WebSocket
# before the backend tears down the underlying CDP endpoint.
_stop_cdp_supervisor(task_id)
@@ -2379,32 +2609,33 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
logger.debug("cleanup_browser called for task_id: %s", task_id)
logger.debug("Active sessions: %s", list(_active_sessions.keys()))
# Check if session exists (under lock), but don't remove yet -
# _run_browser_command needs it to build the close command.
with _cleanup_lock:
session_info = _active_sessions.get(task_id)
if session_info:
bb_session_id = session_info.get("bb_session_id", "unknown")
logger.debug("Found session for task %s: bb_session_id=%s", task_id, bb_session_id)
# Stop auto-recording before closing (saves the file)
_maybe_stop_recording(task_id)
# Try to close via agent-browser first (needs session in _active_sessions)
try:
_run_browser_command(task_id, "close", [], timeout=10)
logger.debug("agent-browser close command completed for task %s", task_id)
except Exception as e:
logger.warning("agent-browser close failed for task %s: %s", task_id, e)
# Now remove from tracking under lock
with _cleanup_lock:
_active_sessions.pop(task_id, None)
_session_last_activity.pop(task_id, None)
# Cloud mode: close the cloud browser session via provider API
# Cloud mode: close the cloud browser session via provider API.
# Local sidecars have bb_session_id=None so this no-ops for them.
if bb_session_id:
provider = _get_cloud_provider()
if provider is not None:
+2 -1
View File
@@ -440,9 +440,10 @@ def _get_or_create_env(task_id: str):
_active_environments, _env_lock, _create_environment,
_get_env_config, _last_activity, _start_cleanup_thread,
_creation_locks, _creation_locks_lock, _task_env_overrides,
_resolve_container_task_id,
)
effective_task_id = task_id or "default"
effective_task_id = _resolve_container_task_id(task_id)
# Fast path: environment already exists
with _env_lock:
+16 -2
View File
@@ -88,8 +88,14 @@ def _resolve_path(filepath: str, task_id: str = "default") -> Path:
def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
"""Return the task's live terminal cwd for bookkeeping when available."""
try:
from tools.terminal_tool import _resolve_container_task_id
container_key = _resolve_container_task_id(task_id)
except Exception:
container_key = task_id
with _file_ops_lock:
cached = _file_ops_cache.get(task_id)
cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id)
if cached is not None:
live_cwd = getattr(getattr(cached, "env", None), "cwd", None) or getattr(
cached, "cwd", None
@@ -101,7 +107,7 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
from tools.terminal_tool import _active_environments, _env_lock
with _env_lock:
env = _active_environments.get(task_id)
env = _active_environments.get(container_key) or _active_environments.get(task_id)
live_cwd = getattr(env, "cwd", None) if env is not None else None
if live_cwd:
return live_cwd
@@ -261,15 +267,23 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
Thread-safe: uses the same per-task creation locks as terminal_tool to
prevent duplicate sandbox creation from concurrent tool calls.
Note: subagent task_ids are collapsed to "default" via
``_resolve_container_task_id`` so delegate_task children share the
parent's container and its cached file_ops. RL/benchmark task_ids with
a registered env override keep their isolation.
"""
from tools.terminal_tool import (
_active_environments, _env_lock, _create_environment,
_get_env_config, _last_activity, _start_cleanup_thread,
_creation_locks,
_creation_locks_lock,
_resolve_container_task_id,
)
import time
task_id = _resolve_container_task_id(task_id)
# Fast path: check cache -- but also verify the underlying environment
# is still alive (it may have been killed by the cleanup thread).
with _file_ops_lock:
+1 -1
View File
@@ -776,7 +776,7 @@ class ProcessRegistry:
# Only enqueue completion notification on the FIRST move. Without
# this guard, kill_process() and the reader thread can both call
# _move_to_finished(), producing duplicate [SYSTEM: ...] messages.
# _move_to_finished(), producing duplicate [IMPORTANT: ...] messages.
if was_running and session.notify_on_complete:
from tools.ansi_strip import strip_ansi
output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
+11
View File
@@ -20,6 +20,13 @@ logger = logging.getLogger(__name__)
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
# Slack conversation IDs: C (public channel), G (private/group channel), D (DM).
# Must be uppercase alphanumeric, 9+ chars. User IDs (U...) and workspace IDs
# (W...) are NOT valid chat.postMessage channel values — posting to them fails
# because the API requires a conversation ID. To DM a user you must first call
# conversations.open to obtain a D... ID. Without this gate, Slack IDs fall
# through to channel-name resolution, which only matches by name and fails.
_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$")
_WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$")
# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
_NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
@@ -318,6 +325,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
match = _NUMERIC_TOPIC_RE.fullmatch(target_ref)
if match:
return match.group(1), match.group(2), True
if platform_name == "slack":
match = _SLACK_TARGET_RE.fullmatch(target_ref)
if match:
return match.group(1), None, True
if platform_name == "weixin":
match = _WEIXIN_TARGET_RE.fullmatch(target_ref)
if match:
+32 -3
View File
@@ -803,6 +803,31 @@ def clear_task_env_overrides(task_id: str):
"""
_task_env_overrides.pop(task_id, None)
def _resolve_container_task_id(task_id: Optional[str]) -> str:
"""
Map a tool-call ``task_id`` to the container/sandbox key used by
``_active_environments``.
The top-level agent passes ``task_id=None`` and lands on ``"default"``.
``delegate_task`` children pass their own subagent ID so that
file-state tracking, the active-subagents registry, and TUI events stay
distinct per child -- but we deliberately collapse that ID back to
``"default"`` here so subagents share the parent's long-lived container
(one bash, one /workspace, one set of installed packages).
Exception: RL / benchmark environments (TerminalBench2, HermesSweEnv, ...)
call ``register_task_env_overrides(task_id, {...})`` to request a
per-task Docker/Modal image. When an override is registered for a
task_id, we honour it by returning the task_id unchanged -- those
rollouts need their own isolated sandbox, which is the whole point of
the override.
"""
if task_id and task_id in _task_env_overrides:
return task_id
return "default"
# Configuration from environment variables
def _parse_env_var(name: str, default: str, converter=int, type_label: str = "integer"):
@@ -1139,8 +1164,9 @@ def _stop_cleanup_thread():
def get_active_env(task_id: str):
"""Return the active BaseEnvironment for *task_id*, or None."""
lookup = _resolve_container_task_id(task_id)
with _env_lock:
return _active_environments.get(task_id)
return _active_environments.get(lookup) or _active_environments.get(task_id)
def is_persistent_env(task_id: str) -> bool:
@@ -1473,8 +1499,11 @@ def terminal_tool(
config = _get_env_config()
env_type = config["env_type"]
# Use task_id for environment isolation
effective_task_id = task_id or "default"
# Use task_id for environment isolation. By default all subagent
# task_ids collapse back to "default" so the top-level agent and
# every delegate_task child share one container; only task_ids with
# a registered env override (RL benchmarks) get isolated sandboxes.
effective_task_id = _resolve_container_task_id(task_id)
# Check per-task overrides (set by environments like TerminalBench2Env)
# before falling back to global env var config
+20
View File
@@ -2321,6 +2321,26 @@ def _(rid, params: dict) -> dict:
payload["rendered"] = rendered
_emit("message.complete", sid, payload)
if (
status == "complete"
and isinstance(raw, str)
and raw.strip()
and isinstance(text, str)
and text.strip()
):
try:
from agent.title_generator import maybe_auto_title
maybe_auto_title(
_get_db(),
session.get("session_key") or sid,
text,
raw,
session.get("history", []),
)
except Exception:
pass
# CLI parity: when voice-mode TTS is on, speak the agent reply
# (cli.py:_voice_speak_response). Only the final text — tool
# calls / reasoning already stream separately and would be
+23
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
boundedLiveRenderText,
edgePreview,
estimateRows,
estimateTokensRough,
@@ -106,3 +107,25 @@ describe('estimateRows', () => {
expect(estimateRows(snake, w)).toBe(estimateRows(plain, w))
})
})
describe('boundedLiveRenderText', () => {
it('keeps short text unchanged', () => {
expect(boundedLiveRenderText('alpha\nbeta', { maxChars: 50, maxLines: 5 })).toBe('alpha\nbeta')
})
it('keeps the tail of long live text', () => {
const text = Array.from({ length: 6 }, (_, i) => `line-${i + 1}`).join('\n')
const out = boundedLiveRenderText(text, { maxChars: 100, maxLines: 3 })
expect(out).toContain('omitted 3 lines')
expect(out.endsWith('line-4\nline-5\nline-6')).toBe(true)
expect(out).not.toContain('line-1')
})
it('bounds very long single-line text by chars', () => {
const out = boundedLiveRenderText('a'.repeat(60), { maxChars: 12, maxLines: 5 })
expect(out).toContain('omitted 48 chars')
expect(out.endsWith('a'.repeat(12))).toBe(true)
})
})
+2 -1
View File
@@ -2,6 +2,7 @@ import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
estimateTokensRough,
isTransientTrailLine,
@@ -492,7 +493,7 @@ class TurnController {
this.streamTimer = null
const raw = this.bufRef.trimStart()
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
patchTurnState({ streaming: visible })
patchTurnState({ streaming: boundedLiveRenderText(visible) })
}, STREAM_BATCH_MS)
}
+6 -2
View File
@@ -5,7 +5,7 @@ import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
@@ -84,7 +84,11 @@ export const MessageLine = memo(function MessageLine({
}
if (msg.role === 'assistant') {
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
return isStreaming ? (
<Text color={body}>{boundedLiveRenderText(msg.text)}</Text>
) : (
<Md compact={compact} t={t} text={msg.text} />
)
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
+9 -3
View File
@@ -16,6 +16,7 @@ import {
widthByDepth
} from '../lib/subagentTree.js'
import {
boundedLiveRenderText,
compactPreview,
estimateTokensRough,
fmtK,
@@ -633,7 +634,12 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const preview = useMemo(() => {
const raw = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
return mode === 'full' ? boundedLiveRenderText(raw) : raw
}, [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
if (!preview && !active) {
@@ -868,8 +874,8 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || busy
const thinkingLive = reasoningActive || reasoningStreaming
const hasThinking = !!cot || thinkingLive
const tokenCount =
reasoningTokens && reasoningTokens > 0 ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0
@@ -1002,7 +1008,7 @@ export const ToolTrail = memo(function ToolTrail({
open: openThinking,
render: rails => (
<Thinking
active={reasoningActive}
active={thinkingLive}
branch="last"
mode="full"
rails={rails}
+2
View File
@@ -1,4 +1,6 @@
export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const LIVE_RENDER_MAX_CHARS = 16_000
export const LIVE_RENDER_MAX_LINES = 240
export const LONG_MSG = 300
export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160
+3 -1
View File
@@ -14,7 +14,9 @@ const gw = new GatewayClient()
gw.start()
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`
snap.source === 'heap'
? `hermes-tui: ${snap.level} heap (${formatBytes(snap.heapUsed)}, rss ${formatBytes(snap.rss)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`
: `hermes-tui: ${snap.level} rss (${formatBytes(snap.rss)}, native ${formatBytes(snap.nativeUsed)}) — auto diagnostics → ${dump?.diagPath ?? '(failed)'}\n`
setupGracefulExit({
cleanups: [() => gw.kill()],
+24 -2
View File
@@ -145,11 +145,11 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
// Diagnostics first — heap-snapshot serialization can crash on very large
// heaps, and the JSON sidecar is the most actionable artifact if so.
const diagnostics = await captureMemoryDiagnostics(trigger)
const dir = process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps')
const dir = memoryDumpDir()
await mkdir(dir, { recursive: true })
const base = `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}`
const base = memoryDumpBase(trigger)
const heapPath = join(dir, `${base}.heapsnapshot`)
const diagPath = join(dir, `${base}.diagnostics.json`)
@@ -162,6 +162,23 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
}
}
export async function performDiagnosticsDump(trigger: MemoryTrigger = 'manual'): Promise<HeapDumpResult> {
try {
const diagnostics = await captureMemoryDiagnostics(trigger)
const dir = memoryDumpDir()
await mkdir(dir, { recursive: true })
const diagPath = join(dir, `${memoryDumpBase(trigger)}.diagnostics.json`)
await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 })
return { diagPath, success: true }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e), success: false }
}
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0B'
@@ -177,6 +194,11 @@ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
const STARTED_AT = { rss: process.memoryUsage().rss, uptime: process.uptime() }
const memoryDumpDir = () => process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps')
const memoryDumpBase = (trigger: MemoryTrigger) =>
`hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}`
// Returns undefined when the probe isn't available (non-Linux paths, sandboxed FS).
const swallow = async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
try {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const memory = vi.hoisted(() => ({
performDiagnosticsDump: vi.fn(async () => ({ diagPath: '/tmp/diag.json', success: true })),
performHeapDump: vi.fn(async () => ({ heapPath: '/tmp/heap.heapsnapshot', success: true }))
}))
vi.mock('./memory.js', () => memory)
import { type MemorySnapshot, startMemoryMonitor } from './memoryMonitor.js'
const GB = 1024 ** 3
const usage = (heapUsed: number, rss: number): NodeJS.MemoryUsage =>
({
arrayBuffers: 0,
external: 0,
heapTotal: heapUsed,
heapUsed,
rss
}) as NodeJS.MemoryUsage
describe('startMemoryMonitor', () => {
let memoryUsageSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
memory.performDiagnosticsDump.mockClear()
memory.performHeapDump.mockClear()
})
afterEach(() => {
memoryUsageSpy?.mockRestore()
vi.useRealTimers()
})
it('captures diagnostics only for native RSS pressure', async () => {
memoryUsageSpy = vi.spyOn(process, 'memoryUsage').mockReturnValue(usage(100 * 1024 ** 2, 5 * GB))
const snaps: MemorySnapshot[] = []
const stop = startMemoryMonitor({
intervalMs: 1000,
onHigh: snap => snaps.push(snap),
rssHighBytes: 4 * GB
})
await vi.advanceTimersByTimeAsync(1000)
stop()
expect(memory.performDiagnosticsDump).toHaveBeenCalledWith('auto-high')
expect(memory.performHeapDump).not.toHaveBeenCalled()
expect(snaps[0]).toMatchObject({ level: 'high', source: 'rss' })
expect(snaps[0]?.nativeUsed).toBeGreaterThan(4 * GB)
})
it('keeps heap dumps for V8 heap pressure', async () => {
memoryUsageSpy = vi.spyOn(process, 'memoryUsage').mockReturnValue(usage(3 * GB, 3.5 * GB))
const snaps: MemorySnapshot[] = []
const stop = startMemoryMonitor({
intervalMs: 1000,
onCritical: snap => snaps.push(snap)
})
await vi.advanceTimersByTimeAsync(1000)
stop()
expect(memory.performHeapDump).toHaveBeenCalledWith('auto-critical')
expect(memory.performDiagnosticsDump).not.toHaveBeenCalled()
expect(snaps[0]).toMatchObject({ level: 'critical', source: 'heap' })
})
})
+37 -8
View File
@@ -1,11 +1,14 @@
import { type HeapDumpResult, performHeapDump } from './memory.js'
import { type HeapDumpResult, performDiagnosticsDump, performHeapDump } from './memory.js'
export type MemoryLevel = 'critical' | 'high' | 'normal'
export type MemoryTriggerSource = 'heap' | 'rss'
export interface MemorySnapshot {
heapUsed: number
level: MemoryLevel
nativeUsed: number
rss: number
source: MemoryTriggerSource
}
export interface MemoryMonitorOptions {
@@ -14,35 +17,61 @@ export interface MemoryMonitorOptions {
intervalMs?: number
onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
rssCriticalBytes?: number
rssHighBytes?: number
}
const GB = 1024 ** 3
const maxLevel = (heapLevel: MemoryLevel, rssLevel: MemoryLevel): MemoryLevel => {
if (heapLevel === 'critical' || rssLevel === 'critical') {
return 'critical'
}
return heapLevel === 'high' || rssLevel === 'high' ? 'high' : 'normal'
}
export function startMemoryMonitor({
criticalBytes = 2.5 * GB,
highBytes = 1.5 * GB,
intervalMs = 10_000,
onCritical,
onHigh
onHigh,
rssCriticalBytes = 8 * GB,
rssHighBytes = 4 * GB
}: MemoryMonitorOptions = {}): () => void {
const dumped = new Set<Exclude<MemoryLevel, 'normal'>>()
const dumped = new Set<`${MemoryTriggerSource}:${Exclude<MemoryLevel, 'normal'>}`>()
const tick = async () => {
const { heapUsed, rss } = process.memoryUsage()
const level: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
const nativeUsed = Math.max(0, rss - heapUsed)
const heapLevel: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
const rssLevel: MemoryLevel = rss >= rssCriticalBytes ? 'critical' : rss >= rssHighBytes ? 'high' : 'normal'
const level = maxLevel(heapLevel, rssLevel)
if (level === 'normal') {
return void dumped.clear()
}
if (dumped.has(level)) {
const source: MemoryTriggerSource =
heapLevel === level || (heapLevel !== 'normal' && rssLevel === level) ? 'heap' : 'rss'
const key = `${source}:${level}` as const
if (dumped.has(key)) {
return
}
dumped.add(level)
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
dumped.add(key)
const snap: MemorySnapshot = { heapUsed, level, rss }
const trigger = level === 'critical' ? 'auto-critical' : 'auto-high'
const dump =
source === 'heap'
? await performHeapDump(trigger).catch(() => null)
: await performDiagnosticsDump(trigger).catch(() => null)
const snap: MemorySnapshot = { heapUsed, level, nativeUsed, rss, source }
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
}
+56 -1
View File
@@ -1,4 +1,4 @@
import { THINKING_COT_MAX } from '../config/limits.js'
import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, THINKING_COT_MAX } from '../config/limits.js'
import type { ThinkingMode } from '../types.js'
const ESC = String.fromCharCode(27)
@@ -76,6 +76,61 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
}
export const boundedLiveRenderText = (
text: string,
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
) => {
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
return text
}
let start = 0
let idx = text.length
for (let seen = 0; seen < maxLines && idx > 0; seen++) {
idx = text.lastIndexOf('\n', idx - 1)
start = idx < 0 ? 0 : idx + 1
if (idx < 0) {
break
}
}
const lineStart = start
start = Math.max(lineStart, text.length - maxChars)
if (start > lineStart) {
const nextBreak = text.indexOf('\n', start)
if (nextBreak >= 0 && nextBreak < text.length - 1) {
start = nextBreak + 1
}
}
const tail = text.slice(start).trimStart()
const omittedLines = countNewlines(text, start)
const omittedChars = Math.max(0, text.length - tail.length)
const label =
omittedLines > 0
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n`
return `${label}${tail.trimStart()}`
}
const countNewlines = (text: string, end: number) => {
let count = 0
for (let i = 0; i < end; i++) {
if (text.charCodeAt(i) === 10) {
count++
}
}
return count
}
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
export const toolTrailLabel = (name: string) =>
+28
View File
@@ -41,6 +41,7 @@ hermes [global-options] <command> [subcommand/options]
| `hermes gateway` | Run or manage the messaging gateway service. |
| `hermes setup` | Interactive setup wizard for all or part of the configuration. |
| `hermes whatsapp` | Configure and pair the WhatsApp bridge. |
| `hermes slack` | Slack helpers (currently: generate the app manifest with every command as a native slash). |
| `hermes auth` | Manage credentials — add, list, remove, reset, set strategy. Handles OAuth flows for Codex/Nous/Anthropic. |
| `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. |
| `hermes status` | Show agent, auth, and platform status. |
@@ -221,6 +222,33 @@ hermes whatsapp
Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing.
## `hermes slack`
```bash
hermes slack manifest # print manifest to stdout
hermes slack manifest --write # write to ~/.hermes/slack-manifest.json
hermes slack manifest --slashes-only # just the features.slash_commands array
```
Generates a Slack app manifest that registers every gateway command in
`COMMAND_REGISTRY` (`/btw`, `/stop`, `/model`, …) as a first-class
Slack slash command — matching Discord and Telegram parity. Paste the
output into your Slack app config at
[https://api.slack.com/apps](https://api.slack.com/apps) → your app →
**Features → App Manifest → Edit**, then **Save**. Slack prompts for
reinstall if scopes or slash commands changed.
| Flag | Default | Purpose |
|------|---------|---------|
| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.json`. |
| `--name NAME` | `Hermes` | Bot display name in Slack. |
| `--description DESC` | default blurb | Bot description shown in the Slack app directory. |
| `--slashes-only` | off | Emit only `features.slash_commands` for merging into a manually-maintained manifest. |
Run `hermes slack manifest --write` again after `hermes update` to pick
up any new commands.
## `hermes login` / `hermes logout` *(Deprecated)*
:::caution
+3 -1
View File
@@ -146,7 +146,9 @@ terminal:
**Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle).
**Container lifecycle:** Each session starts a long-lived container (`docker run -d ... sleep 2h`). Commands run via `docker exec` with a login shell. On cleanup, the container is stopped and removed.
**Container lifecycle:** Hermes reuses a single long-lived container (`docker run -d ... sleep 2h`) for every terminal and file-tool call, across sessions, `/new`, `/reset`, and `delegate_task` subagents, for the lifetime of the Hermes process. Commands run via `docker exec` with a login shell, so working-directory changes, installed packages, and files in `/workspace` all persist from one tool call to the next. The container is stopped and removed on Hermes shutdown (or when the idle-sweep reclaims it).
Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.
**Security hardening:**
- `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back
@@ -86,6 +86,40 @@ FIRECRAWL_API_URL=http://localhost:3002
FIRECRAWL_BROWSER_TTL=600
```
### Hybrid routing: cloud for public URLs, local for LAN/localhost
When a cloud provider is configured, Hermes auto-spawns a **local Chromium sidecar**
for URLs that resolve to a private/loopback/LAN address (`localhost`, `127.0.0.1`,
`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `*.local`, `*.lan`, `*.internal`,
IPv6 loopback `::1`, link-local `169.254.x.x`). Public URLs continue to use the
cloud provider in the same conversation.
This solves the common "I'm developing locally but using Browserbase" workflow —
the agent can screenshot your dashboard at `http://localhost:3000` AND scrape
`https://github.com` without you switching providers or disabling the SSRF guard.
The cloud provider never sees the private URL.
The feature is **on by default**. To disable it (all URLs go to the configured
cloud provider, as before):
```yaml
# ~/.hermes/config.yaml
browser:
cloud_provider: browserbase
auto_local_for_private_urls: false
```
With auto-routing disabled, private URLs are rejected with
`"Blocked: URL targets a private or internal address"` unless you also set
`browser.allow_private_urls: true` (which lets the cloud provider attempt them —
usually won't work since Browserbase etc. can't reach your LAN).
Requirements: the local sidecar uses the same `agent-browser` CLI as pure local
mode, so you need it installed (`hermes setup tools → Browser Automation`
auto-installs it). Post-navigation redirects from a public URL onto a private
address are still blocked (you can't use a redirect-to-internal trick to reach
your LAN through the public path).
### Camofox local mode
[Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies.
+79 -3
View File
@@ -29,13 +29,36 @@ the steps below.
## Step 1: Create a Slack App
The fastest path is to paste a manifest Hermes generates for you. It
declares every built-in slash command (`/btw`, `/stop`, `/model`, …),
every required OAuth scope, every event subscription, and enables Socket
Mode — all at once.
### Option A: From a Hermes-generated manifest (recommended)
1. Generate the manifest:
```bash
hermes slack manifest --write
```
This writes `~/.hermes/slack-manifest.json` and prints paste-in
instructions.
2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →
**Create New App** → **From an app manifest**
3. Pick your workspace, paste the JSON contents, review, click **Next**
→ **Create**
4. Skip ahead to **Step 6: Install App to Workspace**. The manifest
handled scopes, events, and slash commands for you.
### Option B: From scratch (manual)
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps)
2. Click **Create New App**
3. Choose **From scratch**
4. Enter an app name (e.g., "Hermes Agent") and select your workspace
5. Click **Create App**
You'll land on the app's **Basic Information** page.
You'll land on the app's **Basic Information** page. Continue with
Steps 26 below.
---
@@ -59,7 +82,8 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco
:::caution Missing scopes = missing features
Without `channels:history` and `groups:history`, the bot **will not receive messages in channels**
it will only work in DMs. These are the most commonly missed scopes.
it will only work in DMs. Without `files:read`, Hermes can chat but **cannot reliably read user-uploaded attachments**.
These are the most commonly missed scopes.
:::
**Optional scopes:**
@@ -203,6 +227,57 @@ The bot will **not** automatically join channels. You must invite it to each cha
---
## Slash Commands
Every Hermes command (`/btw`, `/stop`, `/new`, `/model`, `/help`, ...)
is a native Slack slash command — exactly the way they work on Telegram
and Discord. Type `/` in Slack and the autocomplete picker lists every
Hermes command with its description.
Under the hood: Hermes ships with a generated Slack app manifest (see
Step 1, Option A) that declares every command in
[`COMMAND_REGISTRY`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/commands.py)
as a slash command. In Socket Mode, Slack routes the command event
through the WebSocket regardless of the manifest's `url` field.
### Refreshing slash commands after updates
When Hermes adds new commands (e.g. after `hermes update`), regenerate
the manifest and update your Slack app:
```bash
hermes slack manifest --write
```
Then in Slack:
1. Open [https://api.slack.com/apps](https://api.slack.com/apps) →
your Hermes app
2. **Features → App Manifest → Edit**
3. Paste the new contents of `~/.hermes/slack-manifest.json`
4. **Save**. Slack will prompt to reinstall the app if scopes or slash
commands changed.
### Legacy `/hermes <subcommand>` still works
For backward compatibility with older manifests, you can still type
`/hermes btw run the tests` — Hermes routes it the same way as `/btw
run the tests`. Free-form questions also work: `/hermes what's the
weather?` is treated as a regular message.
### Advanced: emit only the slash-commands array
If you maintain your Slack manifest by hand and just want the slash
command list:
```bash
hermes slack manifest --slashes-only > /tmp/slashes.json
```
Paste that array into the `features.slash_commands` key of your
existing manifest.
---
## How the Bot Responds
Understanding how Hermes behaves in different contexts:
@@ -446,7 +521,8 @@ Keys are Slack channel IDs (find them via channel details → "About" → scroll
| "Sending messages to this app has been turned off" in DMs | Enable the **Messages Tab** in App Home settings (see Step 5) |
| "not_authed" or "invalid_auth" errors | Regenerate your Bot Token and App Token, update `.env` |
| Bot responds but can't post in a channel | Invite the bot to the channel with `/invite @Hermes Agent` |
| "missing_scope" error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
| Bot can chat but can't read uploaded images/files | Add `files:read`, then **reinstall** the app. Hermes now surfaces attachment access diagnostics in-chat when Slack returns scope/auth/permission failures. |
| `missing_scope` error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
| Socket disconnects frequently | Check your network; Bolt auto-reconnects but unstable connections cause lag |
| Changed scopes/events but nothing changed | You **must reinstall** the app to your workspace after any scope or event subscription change |
-1
View File
@@ -26,7 +26,6 @@ CATEGORY_LABELS = {
"dogfood": "Dogfood",
"domain": "Domain",
"email": "Email",
"feeds": "Feeds",
"gaming": "Gaming",
"gifs": "GIFs",
"github": "GitHub",
+1 -15
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"updated_at": "2026-04-26T12:34:42Z",
"updated_at": "2026-04-26T19:27:12Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
@@ -16,14 +16,6 @@
"id": "moonshotai/kimi-k2.6",
"description": "recommended"
},
{
"id": "deepseek/deepseek-v4-pro",
"description": ""
},
{
"id": "deepseek/deepseek-v4-flash",
"description": ""
},
{
"id": "anthropic/claude-opus-4.7",
"description": ""
@@ -163,12 +155,6 @@
{
"id": "moonshotai/kimi-k2.6"
},
{
"id": "deepseek/deepseek-v4-pro"
},
{
"id": "deepseek/deepseek-v4-flash"
},
{
"id": "xiaomi/mimo-v2.5-pro"
},