Compare commits

...
Author SHA1 Message Date
Teknium 2e48537cf1 chore(release): map xxxigm author email 2026-06-13 14:58:19 -07:00
xxxigm af1477d812 fix(codex): bound leaked tool-call scan to prefix window 2026-06-13 14:58:19 -07:00
Teknium bf8effad02 fix(utils): copy fallback for atomic replace across devices (#43852)
Fallback from `os.replace` on EXDEV/EBUSY using copy+fsync+unlink while preserving symlink target semantics and metadata.
2026-06-13 14:50:05 -07:00
Teknium 817f392311 feat(read): extract notebook and office documents (#37082)
Add stdlib-only extraction for `.ipynb`, `.docx`, and `.xlsx` in read_file with lazy integration and malformed-document fallback.
2026-06-13 14:42:51 -07:00
Teknium 2b67e96aec fix(approval): gate in-place edits to sensitive user files
Cover sed, perl, and ruby in-place mutations against shell rc, SSH, and credential files so terminal approvals pair the redirection and copy guards.
2026-06-13 14:35:27 -07:00
helix4u abd69b8117 fix(approval): detect absolute home shell rc writes 2026-06-13 14:35:27 -07:00
briandevans da28d5d113 fix(security): gate cp/mv/install into ~/.ssh, credential, and shell-rc files
tools/approval.py already denies tee/redirection writes to every
_SENSITIVE_WRITE_TARGET (~/.ssh/*, ~/.netrc/.pgpass/.npmrc/.pypirc, shell
rc files, ~/.hermes/config.yaml/.env) via the DANGEROUS_PATTERNS tee/`>`
rules, but cp/mv/install were only paired for _SYSTEM_CONFIG_PATH (/etc) and
the project-relative env/config target. So `cp evil ~/.ssh/authorized_keys`
(SSH-key implant / persistence), `cp creds ~/.netrc`, and `cp evil ~/.bashrc`
(login-time command injection) auto-approved while the equivalent tee/`>`
forms were denied — an unpaired write deny is theater (same rationale as
#14639 / commit 4e9d886d, which paired the terminal side for
~/.hermes/config.yaml writes but did not touch these cp/mv/install verbs on
the broader sensitive set).

Add one (cp|mv|install) DANGEROUS_PATTERNS entry reusing the existing
_SENSITIVE_WRITE_TARGET fragment, anchored via _COMMAND_TAIL so it fires on
the destination (last arg) only: reading OUT of a sensitive path
(`cp ~/.ssh/config /tmp/x`) stays auto-approved. Description differs from the
system-config cp entry so the two keep distinct approval keys (no silent
cross-approval). Additive — does not subsume the /etc or project-config rules.

Adds TestSensitiveCopyMovePattern: 5 positive cases (ssh authorized_keys,
ssh private key via mv, netrc via install, bashrc, ~/.hermes/config.yaml) +
2 negative guards (copy FROM ssh, unrelated copy). The ssh/netrc/bashrc
positives fail on main and pass on this branch; the negatives stay green
both ways.
2026-06-13 14:35:27 -07:00
Teknium 1fa761f8de fix(search): keep partial results on search timeout (#36142)
Treat search command budget timeouts as soft truncation so partial results survive, while real search failures still return structured errors.
2026-06-13 14:35:21 -07:00
Teknium 069bfd6545 fix(agent): keep Codex reasoning replay on Codex path 2026-06-13 14:35:00 -07:00
briandevans 1d584a301e fix(agent): treat Codex reasoning items as thinking-only 2026-06-13 14:35:00 -07:00
ITheEqualizer 57c2a55be4 fix(telegram): harden rich message fallback handling
Carry forward focused follow-ups from PR #45741: treat PTB's raw Bot API 10.1 response shapes safely, recognize real missing-endpoint errors, preserve link preview settings on rich sends, and lock the rich limit to Telegram's character-based cap.
2026-06-13 14:34:53 -07:00
brooklyn! 0a865e5948 fix(desktop): bypass Chromium editing pipeline for large paste & select-delete (#45812)
Large paste and Ctrl+A → Delete froze the composer for seconds — both routed
through Chromium's contenteditable editing pipeline (~O(n²) on multiline DOM).

- insertPlainTextAtCaret: Range + text/<br> fragment (paste path)
- deleteSelectionInEditor: range.deleteContents for non-collapsed Backspace/Delete
- Shared composerSelectionRange helper; both flush via flushEditorToDraft

Profiled live (47 KB / 122 paragraphs): paste 4474 ms → 13 ms; select-delete
1304 ms → 4 ms. Collapsed-caret deletes still native.
2026-06-13 20:49:58 +00:00
Teknium c8e5f34f24 fix(gemini): strip native self prefixes before generateContent (#36141)
Strip `google/` and `gemini/` self-prefixes before native Gemini generateContent calls, and keep provider-normalization expectations aligned.
2026-06-13 13:47:08 -07:00
briandevans 7d11fa4e9e fix(codex-responses): let final_answer complete top-level incomplete responses 2026-06-13 13:45:29 -07:00
ITheEqualizer 7c0605bf22 fix(telegram): preserve rich formatting on stream final 2026-06-13 13:44:45 -07:00
achaljhawar 819def44c7 fix(agent): scope Nous tags to Nous auxiliary calls 2026-06-13 13:24:40 -07:00
Teknium 08890d77e6 fix(plugins): normalize browser-pasted GitHub repo URLs (#33539)
Accept common GitHub web URLs in `hermes plugins install` by normalizing repository views back to cloneable `.git` URLs, with focused parser coverage.
2026-06-13 13:23:59 -07:00
brooklyn! 425e777f54 fix(desktop): polish slash command completion (space/tab/click + typed args) (#45760)
* fix(desktop): accept slash command on space at command stage

Pressing space on a no-arg slash command (e.g. /hermes-agent) fell
through to the arg-completion stage and dead-ended on "No matches"
instead of inserting the directive. Space now mirrors Tab/Enter while
the command name is still being typed: no-arg commands commit the chip,
arg-taking commands expand to their options step.

* fix(desktop): suppress arg popover for no-arg slash commands

Committing a no-arg command (`/hermes-agent `) re-detected the chip+space
as an arg query and re-opened the popover on "No matches". The arg-stage
menu now only opens when the command actually takes args.

* fix(desktop): polish slash arg completion (space/tab/click + typed args)

Unify Enter/Tab/Space accept of the highlighted item at both the command
and arg stages: no-arg commands commit a chip, arg commands expand to
options, and an arg option commits the full `/cmd arg` chip. A fully-typed
arg (which the backend completer drops from suggestions) now commits on
Space/Tab via the verbatim text instead of dead-ending, and the "No
matches" empty state is suppressed past a command's name. Space stays
slash-only so @ mentions keep a literal space.
2026-06-13 18:43:52 +00:00
kshitij 7be22e37e1 Merge pull request #45753 from kshitijk4poor/salvage/gateway-auto-resume-duplicate-agent
fix(gateway): claim session slot before auto-resume task to prevent duplicate agents (#45456)
2026-06-13 23:46:17 +05:30
kshitijk4poor 28902dc890 chore: map liuhao1024 contributor email for attribution 2026-06-13 23:39:49 +05:30
kshitijk4poor 63097ee0d7 test(gateway): cover auto-resume full-path no-regression; clarify guard docstring
The salvaged fix's two regression tests mock adapter.handle_message, so
they only assert the pre-claimed sentinel is set/cleaned around a stub —
they never drive the real dispatch chain. Add a full-path test that
exercises _schedule_resume_pending_sessions -> _guarded_handle_message ->
adapter.handle_message -> _process_message_background -> _handle_message
and asserts the resumed session's agent runs EXACTLY ONCE: not zero (the
pre-claim must not self-bounce the resume into a queued no-op) and not
twice (the duplicate-agent bug #45456 the fix targets). Also assert no
leaked sentinel and no orphaned pending event after the drain settles.

Tighten the _guarded_handle_message docstring: on current main the real
sentinel is taken over inside _handle_message (not _process_message_background),
and note the `is _AGENT_PENDING_SENTINEL` guard only releases the slot we
ourselves placed, never one a live run owns.
2026-06-13 23:39:35 +05:30
liuhao1024 6e2fd955ca fix(gateway): claim session slot before auto-resume task to prevent duplicate agents
When the gateway restarts and auto-resumes an interrupted session, an
inbound message arriving in the window between `asyncio.create_task()`
and the task's first await could spin up a second AIAgent for the same
session.  Both agents would then process messages concurrently,
producing interleaved duplicate responses (#45456).

Fix: set `_AGENT_PENDING_SENTINEL` in `_running_agents` immediately
after the "already running" check, before creating the task.  This
closes the race window — any inbound message sees the slot as occupied
and queues behind the auto-resume.

A `_guarded_handle_message` wrapper ensures the pre-claimed sentinel is
always released, even if `handle_message` raises before reaching
`_process_message_background` (whose `finally` block handles normal
cleanup).

(cherry picked from commit 85150c976b)
2026-06-13 23:36:51 +05:30
helix4u 78c11d99e3 fix(update): stop Windows gateways before mutating install 2026-06-13 10:46:08 -07:00
ashishpatel26andTranquil-Flow 957a8ffa88 fix(bedrock): omit sampling params for restricted Claude models
Bedrock Converse rejects non-default sampling parameters for Opus 4.7 and 4.8 with a ValidationException. Reuse the Anthropic-native sampling-param guard in the Bedrock kwargs builder so those models omit temperature/topP while older Claude and non-Claude models keep existing behavior.

Includes the stop-sequence regression from the parallel fix to ensure stopSequences still pass through for restricted Opus models.

Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
2026-06-13 10:45:56 -07:00
Teknium cc14b74718 docs(profile): update clone-from references 2026-06-13 07:33:58 -07:00
Teknium 9b5f7b63c6 fix(profile): make clone-from a full source selector 2026-06-13 07:33:58 -07:00
Teknium d146b85173 chore(release): map WompaJango author 2026-06-13 07:33:58 -07:00
WompaJango 28bf8fb47d feat(dashboard): clone profiles from any source 2026-06-13 07:33:58 -07:00
Que0x 3380563d94 fix(security): stop /api/status leaking host paths and PID on gated binds
The dashboard's public /api/status liveness endpoint is in PUBLIC_API_PATHS
and bypasses dashboard auth, yet it returned absolute hermes_home,
config_path, env_path, the gateway PID, and the internal gateway health URL.
That exceeds the shape its own allowlist documents as public ("version,
gateway state, active session count, and the dashboard auth-gate shape. No
bodies, no session content, no secrets"), leaking deployment recon to any
unauthenticated caller on a network-exposed (gated) bind.

Withhold host-local detail unless the bind is loopback / --insecure, where
the dashboard is local-only and the caller is already inside the trust
envelope -- the same split should_require_auth draws. The NAS liveness probe
and the auth-gate badge are unaffected.

Adds invariant tests for both modes (gated withholds, loopback keeps).
2026-06-13 07:18:59 -07:00
Teknium ad7436a5d9 fix(gateway): preserve WeCom per-group sender allowlists
Keep the own-policy fail-closed hardening from PR #45444, but still trust WeCom groups.<id>.allow_from because the adapter already checked that sender allowlist before dispatching to gateway auth.
2026-06-13 07:18:54 -07:00
Que0x fc46354580 fix(security): fail closed when an own-policy gateway adapter has no allowlist
Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default dm_policy/group_policy to "open", which forwards every sender. The gateway's adapter-trust shortcut in _is_user_authorized blanket-trusted those platforms when no env allowlist was set, so an operator who enabled one with only credentials authorized the entire external network -- the fail-open SECURITY.md section 2.6 forbids ("an allowlist is required for every enabled network-exposed adapter").

Trust the adapter only when its effective policy for the chat type is an actual "allowlist" restriction (the case #34515 was protecting). "open"/"pairing"/anything else falls through to default-deny, where {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS and the pairing flow remain the explicit opt-ins.
2026-06-13 07:18:54 -07:00
Teknium 1185dfd773 test: cover legacy Office document extensions 2026-06-13 07:18:37 -07:00
Clayton Chew f82cb48120 fix(platform): add .xls, .doc, .ppt to SUPPORTED_DOCUMENT_TYPES
Old Office formats (.xls, .doc, .ppt) were missing from the
SUPPORTED_DOCUMENT_TYPES dict in gateway/platforms/base.py while their
newer counterparts (.xlsx, .docx, .pptx) were included.

Sending an .xls file via Telegram triggers 'Unsupported document type'
and the file is silently dropped instead of being cached and forwarded
to the agent.

Add the three legacy MIME types so these files are handled the same way
as their modern equivalents.
2026-06-13 07:18:37 -07:00
Tranquil-Flow 4fd9397ae3 fix(codex): drop extra_headers for chatgpt.com backend 2026-06-13 07:13:24 -07:00
Sarvesh 45f9099e51 fix(matrix): preserve markdown table structure 2026-06-13 06:57:08 -07:00
Teknium 4373e802a1 fix(docs): reuse healthy skills index during Pages deploys (#45616) 2026-06-13 06:46:07 -07:00
Teknium d206e1f51d fix(dashboard): keep local file browser on home 2026-06-13 06:39:38 -07:00
konsisumer 16fb573bae fix(gateway): clear bloated compression binding on compression-exhaustion auto-reset
After compression exhaustion the auto-reset created a fresh session but
discarded reset_session()'s return value and left the Telegram topic
binding pointing at the oversized compressed child. The next inbound
message in that topic healed the binding forward and switch_session'd the
freshly-reset lane back onto the bloated transcript, re-triggering
compression exhaustion in a loop with a new session id each time.

Capture the fresh entry and re-sync the topic binding to it so the next
message starts clean. No-op on non-topic lanes.

Regression of the #9893/#10063 auto-reset fix.

Fixes #35809
2026-06-13 06:38:29 -07:00
Teknium 6f43ff5572 chore(release): map Gemini schema contributor 2026-06-13 06:12:52 -07:00
Henrik Bentel eed61a1251 fix(gemini): add role field to systemInstruction 2026-06-13 06:12:52 -07:00
Teknium 74c5158b10 fix(model): show bare custom endpoints in gateway picker (#45597)
Surface direct model.provider=custom endpoints in /model picker output and keep explicit bare custom switches on the current endpoint instead of requiring a named providers/custom_providers row.
2026-06-13 06:05:30 -07:00
Teknium 6724daa2c2 fix: keep CLI idle timer ticking (#45592) 2026-06-13 05:55:04 -07:00
Teknium aa53a78d67 fix(desktop): hand off Windows bootstrap recovery (#45594) 2026-06-13 05:54:32 -07:00
Teknium 0333a99925 fix: merge session-only model analytics rows (#45582) 2026-06-13 05:52:42 -07:00
Tranquil-Flow 5acd185f7c fix(moonshot): handle union type arrays in tool schemas 2026-06-13 05:51:41 -07:00
Teknium 39a35b784f chore(release): map custom provider resume contributors 2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Opus 4.8 2667601c05 fix(tui): keep reasoning-only assistant turns visible on session resume
A thinking-only assistant turn (reasoning present, empty visible text) is
persisted with its reasoning fields and stays recallable from the transcript,
but `_history_to_messages` dropped it as "empty" before its reasoning was
attached. On desktop/TUI resume or reload the turn therefore vanished from the
session view while the agent could still recall it from a fresh session --
exactly the "messages disappear when the LLM uses its thinking block, but a new
session can recall them" symptom reported on #44022.

Keep an assistant turn when it carries reasoning, even with empty text, so the
desktop "Thinking…" disclosure has something to render. Genuinely empty turns
(no text, no reasoning, no tool calls) are still filtered out.

Refs #44022

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Fable 5 643dc82793 Fix custom provider identity loss in session persistence
_runtime_model_config persisted the live agent's RESOLVED provider into
the session row's model_config JSON. For any named providers:/
custom_providers: entry, agent.provider is the literal string "custom",
so the entry name was lost (and the api_key is deliberately never
persisted). On session.resume or _reset_session_agent the stored
provider="custom" fed resolve_runtime_provider(requested="custom"),
which cannot match a named entry — the rebuild either raised "No LLM
provider configured" or silently resolved placeholder credentials
against the patched-back base_url.

Persist the REQUESTED/entry identity instead: a new reverse lookup
find_custom_provider_identity(base_url) maps the endpoint URL back to
the canonical custom:<name> menu key. _runtime_model_config stores that
key; _make_agent performs the same recovery for rows persisted before
the fix, falling back to passing the stored base_url as
explicit_base_url so the direct-alias branch still targets the
session's endpoint when no entry matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Haozhe Zhang e256f4aae4 fix(gateway): don't restore a bare billing provider as the resumed session's provider
`_stored_session_runtime_overrides` restored the session provider from
`billing_provider` when `model_config` had no explicit provider. For a
`custom:<name>` endpoint that only ran normal turns (no `/model` switch), the
persisted `billing_provider` is the bare billing bucket `"custom"`, which
`agent_init` treats as non-routable, so `session.resume` failed with
"No LLM provider configured" even though new chats and CLI `--resume` work.

Only restore an explicit `model_config.provider`; skip a bare billing bucket
(`auto`/`openrouter`/`custom`) so resume falls back to the configured default,
matching the CLI path.

Fixes #44022
2026-06-13 05:51:05 -07:00
Teknium cb125c2b3f fix(kanban): pin assigned profile toolsets for workers (#45590) 2026-06-13 05:50:09 -07:00
Teknium a59d5e37e8 feat(telegram): make rich messages always on (#45584)
Remove the rich_messages config toggle entirely so Telegram replies always try the Bot API 10.1 rich-message path first, with the existing MarkdownV2 fallback/latch behavior for unsupported endpoints and per-message failures.

Restore the Telegram platform hint to encourage rich Markdown tables/task lists/math now that the rich path is the default, and remove the config/docs surface for the old toggle.
2026-06-13 05:45:11 -07:00
Teknium 4b646bc21e fix(auxiliary): preserve main provider base url (#45587) 2026-06-13 05:44:18 -07:00
Teknium 62b4618e9a fix(dashboard): scope sessions and analytics to selected profile (#45598) 2026-06-13 05:42:38 -07:00
H-Ali13381 2abcae9678 fix(cli): preserve renderer state on resize 2026-06-13 05:40:18 -07:00
xxxigm c814d3d1dd test(installer): regression for unmerged-index update failure
Functional bash test drives install.sh's autostash block against a throwaway
repo with a real conflicted index and asserts the stash now succeeds and the
unmerged entries are cleared (previously `git stash` failed with "could not
write index"). Source-order assertions cover both scripts to ensure the
`git reset` clear runs before `git stash push` (a no-op otherwise).
2026-06-13 05:19:44 -07:00
xxxigm 573b964dc7 fix(installer): clear an unmerged git index before stashing on update
When an existing install at $INSTALL_DIR has an unmerged index (files in a
"needs merge" state left by a previously interrupted update), the update path
ran `git stash` then `git checkout <branch>`. On a conflicted index `git stash`
aborts with "could not write index" and `git checkout` then aborts with "you
need to resolve your current index first" — surfacing to desktop/bootstrap
users as `git checkout main failed (exit 1)` and failing the whole install at
the repository stage.

Mirror the `hermes update` Python path (#4735): detect unmerged entries with
`git ls-files --unmerged` and clear the conflict state with `git reset` before
stashing. Working-tree changes are still captured by the subsequent stash, so
nothing is discarded; only the index-level conflict markers are dropped, which
lets the checkout proceed.

Fixed in both installers (install.sh and install.ps1) so the Windows GUI
installer and the POSIX one share the same recovery behavior.
2026-06-13 05:19:44 -07:00
Teknium aa0798352a fix(auth): self-heal missing Codex access tokens
Recover Codex singleton auth entries that have a refresh token but no access token by adopting a valid Codex CLI token pair, matching the cron-time failure mode before falling back to the credential pool.
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 311ff967de review: validate refresh_token, path-agnostic recovery log, map author email
Addresses PR review feedback:
- Validate refresh_token (not only access_token) before persisting the
  re-imported Codex token, so a half-token payload can't silently break the
  next refresh cycle.
- Make the recovery log path-agnostic ("Codex CLI auth.json") since
  _import_codex_cli_tokens can read $CODEX_HOME, not only ~/.codex.
- Add regression test: relogin-required + imported token missing refresh_token
  -> re-raise and persist nothing.
- Map kenmege@yahoo.com -> Kenmege in scripts/release.py AUTHOR_MAP
  (fixes the check-attribution job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 bd66e7e3fb fix(auth): self-heal Codex refresh_token rotation by reimporting from ~/.codex
Hermes keeps its own copy of the Codex OAuth token per profile and at the
top level, separate from the Codex CLI's ~/.codex/auth.json. OAuth
refresh_tokens are single-use, so when the Codex CLI (or another Hermes
process) rotates the shared token, the frozen copy's refresh_token goes
stale and refresh_codex_oauth_pure fails with a relogin-required error
(invalid_grant / refresh_token_reused / 401). Today that surfaces as a hard
401 on the turn — idle profiles and desktop sessions 401 "token_expired"
until a manual re-auth — even though ~/.codex/auth.json holds a fresh token.

_refresh_codex_auth_tokens now falls back to _import_codex_cli_tokens() (the
canonical Codex CLI store) when the stored refresh_token is rejected, adopts
and persists the fresh token, and lets the in-flight retry succeed. This
complements PR #6525 (force relogin on 401/403): we attempt automatic
recovery before surfacing a relogin prompt. Transient failures (e.g. 429
quota, relogin_required=False) are never self-healed — the stored token is
still valid there — so they re-raise unchanged, and the happy path is
untouched.

Adds tests/hermes_cli/test_auth_codex_self_heal.py covering: self-heal on
invalid_grant, no self-heal on 429 quota, re-raise when ~/.codex is absent,
and happy-path-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Teknium 2681c5a12d fix(photon): correct gateway start command (#45566) 2026-06-13 05:14:59 -07:00
xxxigm fa2aba90b4 docs(docker): explain per-profile gateway ports for multi-profile setups
The Multi-profile section never explained how to reach more than one
profile from outside the container, and distinguishes the two surfaces
that people conflate:

- Hermes Desktop's Remote Gateway connects to a `hermes dashboard`
  backend (port 9119), and a single dashboard serves every co-located
  profile via its profile switcher (the target profile is sent per
  request; the backend opens that profile's HERMES_HOME). No per-profile
  port or second connection is needed for Desktop.
- OpenAI-compatible API clients (Open WebUI, LobeChat, /v1) talk to each
  profile's API server, which binds 8642 for every profile with no
  auto-allocation. Reaching a second profile from such a client needs a
  distinct `API_SERVER_PORT` in that profile's own `.env` (and the port
  must NOT go in the container-wide `environment:` block, or every
  profile collides on it).

Adds the create -> set port -> restart flow, the bridge port-publishing
note, and clarifies the default profile's connection is untouched.
2026-06-13 05:13:25 -07:00
xxxigm 5b857201b7 fix(profiles): correct misleading per-profile gateway port docstrings
The s6 profile-gateway docstrings claimed the bind port comes from a
`[gateway] port` key in config.yaml ("the single source of truth"). No such
key exists or is read anywhere — the API server port is resolved by
gateway/config.py from `API_SERVER_PORT` (or `platforms.api_server.extra.port`)
and defaults to 8642. The wrong reference actively misled a Docker user into
setting a non-functional `gateway.port`.

Point both docstrings (`S6ServiceManager._render_run_script`,
`_maybe_register_gateway_service`) at the real knob, and note the practical
consequence: since each supervised profile gateway loads its own HERMES_HOME,
two profiles left at the default both try to bind 8642 — each needs a distinct
`API_SERVER_PORT` in its own `.env`.
2026-06-13 05:13:25 -07:00
Teknium 905ed413d1 fix(doctor): avoid unsafe npm audit fallback
Root-level npm audit fix can crash with isDescendantOf on the same monorepo tree, so workspace audit advisories should explain the lockfile-bump path instead of recommending another manual npm fix command.
2026-06-13 05:09:56 -07:00
xxxigm bea6c1c01f test(doctor): assert audit-fix hint avoids crashing form and explains build-tool advisories 2026-06-13 05:09:56 -07:00
xxxigm a5e9b17ce3 fix(doctor): stop recommending the npm-crashing audit fix, explain build-tool advisories
`hermes doctor` flagged the web/ui-tui workspaces and told the user to run
`npm audit fix --workspace <name>`, which crashes current npm with
"Cannot read properties of null (reading 'edgesOut')" (an arborist bug with
workspace-filtered audit fix). Recommend the root-level `npm audit fix`
instead.

Even the root form can hit a known npm arborist crash (edgesOut /
isDescendantOf) on this monorepo tree, so add a note that these workspace
advisories are build-time tooling (esbuild/vite, etc.) — not runtime code —
and clear via a lockfile bump rather than a manual fix. This keeps doctor
from handing users a command that errors out and from implying a broken
Hermes install.
2026-06-13 05:09:56 -07:00
xxxigm 5d6c16e972 test(desktop): cover the inline command expander on the approval bar
Asserts the full command is absent until the Command toggle is clicked, then
rendered in full — guarding the long-command reveal path.
2026-06-13 05:08:37 -07:00
xxxigm 266b5a19f1 feat(desktop): expand the full command inline from the approval bar
The native desktop approval bar deliberately omits the command because the
pending tool row "already shows it" — but that row only renders a single
truncated line, and a pending row can't be expanded (it has no result yet). So
the full command was only reachable by opening the "Always allow" dropdown,
reading the modal, cancelling, then clicking Run — 4-5 clicks just to see what
you're approving.

Add a "Command" toggle to the approval bar that reveals the full
`request.command` inline (reusing the dialog's pre styling), default collapsed.
Approving a long command is now "expand, Run". Gated on a non-empty command so
zero-command approvals are unaffected.
2026-06-13 05:08:37 -07:00
Black-KylinandHermes 202e318cb1 fix(gateway): sync compression session splits before failures
Salvages PR #25747 by preserving gateway session rotation even when a post-compression model call fails before returning final content.

Co-authored-by: Hermes <127238744+teknium1@users.noreply.github.com>
2026-06-13 04:51:59 -07:00
helix4u 2d474e39c7 fix(acp): preserve memory provider tools 2026-06-13 04:51:44 -07:00
142 changed files with 6442 additions and 865 deletions
+83 -16
View File
@@ -11,8 +11,20 @@ on:
- 'optional-skills/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
inputs:
skills_index_run_id:
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
required: false
type: string
rebuild_skills_index:
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
required: false
default: false
type: boolean
permissions:
contents: read
actions: read
pages: write
id-token: write
@@ -55,26 +67,81 @@ jobs:
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Build skills index (unified multi-source catalog)
- name: Prepare skills index (unified multi-source catalog)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
# Rebuild the unified catalog. The file is gitignored, so a fresh
# checkout starts without it and we want the freshest crawl in
# every deploy.
# The unified external catalog is expensive to crawl and can burn
# through the repository installation's GitHub API quota when several
# docs deploys land close together. Normal docs deploys therefore
# reuse the latest healthy catalog: first the artifact from a
# scheduled skills-index run, then the currently live index. Only a
# manual force rebuild does a fresh crawl here.
#
# This MUST be fatal. build_skills_index.py runs a health check and
# exits non-zero WITHOUT writing the output file when a source
# collapses (e.g. a GitHub API rate limit zeroes the github /
# claude-marketplace / well-known taps all at once). Letting the
# deploy continue would either (a) ship a degenerate index missing
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
# local-only catalog. Failing here keeps the last good deployment
# live (GitHub Pages serves the previous build) instead of
# publishing a broken catalog. Re-run the workflow once the
# transient rate limit clears.
# If we do crawl, the build remains fatal. build_skills_index.py runs
# the health check BEFORE writing and exits non-zero on source
# collapse, keeping the last good Pages deployment live instead of
# publishing a degenerate catalog.
set -euo pipefail
INDEX_PATH="website/static/api/skills-index.json"
mkdir -p "$(dirname "$INDEX_PATH")"
validate_index() {
python3 - "$INDEX_PATH" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
sys.exit(1)
skills = data.get("skills")
if not isinstance(skills, list) or len(skills) < 1500:
count = len(skills) if isinstance(skills, list) else "missing"
print(f"skills index too small: {count}", file=sys.stderr)
sys.exit(1)
print(f"skills index ready: {len(skills)} skills")
PY
}
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
python3 scripts/build_skills_index.py
validate_index
exit 0
fi
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
tmpdir="$(mktemp -d)"
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
if [ -n "$candidate" ]; then
cp "$candidate" "$INDEX_PATH"
if validate_index; then
exit 0
fi
fi
fi
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
fi
echo "Downloading currently live skills index"
if curl -fsSL --retry 3 --retry-delay 5 \
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
-o "$INDEX_PATH" && validate_index; then
exit 0
fi
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
rm -f "$INDEX_PATH"
python3 scripts/build_skills_index.py
validate_index
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
+1 -1
View File
@@ -53,4 +53,4 @@ jobs:
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
+17
View File
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
enabled_toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
inject_memory_provider_tools(state.agent)
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
)
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
tool_view = SimpleNamespace(
tools=list(tools or []),
valid_tool_names={
tool.get("function", {}).get("name")
for tool in tools or []
if isinstance(tool, dict)
},
enabled_toolsets=toolsets,
_memory_manager=getattr(state.agent, "_memory_manager", None),
)
inject_memory_provider_tools(tool_view)
tools = tool_view.tools
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
+2 -32
View File
@@ -1193,38 +1193,8 @@ def init_agent(
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
# Inject memory provider tool schemas into the tool surface.
# Skip tools whose names already exist (plugins may register the
# same tools via ctx.register_tool(), which lands in agent.tools
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
if isinstance(t, dict)
}
for _schema in agent._memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
if _tname:
agent.valid_tool_names.add(_tname)
_existing_tool_names.add(_tname)
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
_inject_memory_provider_tools(agent)
# Skills config: nudge interval for skill creation reminders
agent._skill_nudge_interval = 10
+9 -1
View File
@@ -881,6 +881,8 @@ def try_recover_primary_transport(
def drop_thinking_only_and_merge_users(
messages: List[Dict[str, Any]],
*,
drop_codex_reasoning_items: bool = True,
) -> List[Dict[str, Any]]:
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
@@ -902,7 +904,13 @@ def drop_thinking_only_and_merge_users(
return messages
# Pass 1: drop thinking-only assistant turns.
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
kept = [
m for m in messages
if not _ra().AIAgent._is_thinking_only_assistant(
m,
drop_codex_reasoning_items=drop_codex_reasoning_items,
)
]
dropped = len(messages) - len(kept)
if dropped == 0:
return messages
+2 -2
View File
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
explicit_base_url = None
explicit_base_url = runtime_base_url or None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
@@ -5004,7 +5004,7 @@ def _build_call_kwargs(
# Provider-specific extra_body
merged_extra = dict(extra_body or {})
if provider == "nous" or auxiliary_is_nous:
if provider == "nous":
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
if merged_extra:
kwargs["extra_body"] = merged_extra
+7 -4
View File
@@ -935,11 +935,14 @@ def build_converse_kwargs(
if system_prompt:
kwargs["system"] = system_prompt
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
from agent.anthropic_adapter import _forbids_sampling_params
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if not _forbids_sampling_params(model):
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if stop_sequences:
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
+21 -2
View File
@@ -70,6 +70,21 @@ _TOOL_CALL_LEAK_PATTERN = re.compile(
r"(?:^|[\s>|])to=functions\.[A-Za-z_][\w.]*",
re.IGNORECASE,
)
_TOOL_CALL_LEAK_SCAN_LIMIT = 8192
def _scan_for_leaked_tool_call(text: str) -> bool:
"""Return True if Codex leaked a Harmony tool-call marker near the start.
Real leaked tool-call serializations begin with the Harmony marker. Bound
the regex to a prefix window so multi-megabyte successful assistant output
never spends unbounded GIL time proving it does not contain a leak.
"""
return bool(
_TOOL_CALL_LEAK_PATTERN.search(
text[:_TOOL_CALL_LEAK_SCAN_LIMIT + 64]
)
)
# ---------------------------------------------------------------------------
@@ -1081,6 +1096,7 @@ def _normalize_codex_response(
message_items_raw: List[Dict[str, Any]] = []
tool_calls: List[Any] = []
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
saw_commentary_phase = False
saw_final_answer_phase = False
saw_reasoning_item = False
@@ -1095,6 +1111,7 @@ def _normalize_codex_response(
if item_status in {"queued", "in_progress", "incomplete"}:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True
if item_type == "message":
item_phase = getattr(item, "phase", None)
@@ -1225,7 +1242,7 @@ def _normalize_codex_response(
# ``function_call`` item. The existing loop already handles message
# append, dedup, and retry budget.
leaked_tool_call_text = False
if final_text and not tool_calls and _TOOL_CALL_LEAK_PATTERN.search(final_text):
if final_text and not tool_calls and _scan_for_leaked_tool_call(final_text):
leaked_tool_call_text = True
logger.warning(
"Codex response contains leaked tool-call text in assistant content "
@@ -1252,7 +1269,9 @@ def _normalize_codex_response(
finish_reason = "tool_calls"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
elif saw_streaming_or_item_incomplete:
finish_reason = "incomplete"
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Response contains only reasoning (encrypted thinking state and/or
+4 -1
View File
@@ -707,7 +707,10 @@ def run_conversation(
# a thinking-only turn. Runs on the per-call copy only — the
# stored conversation history keeps the reasoning block for the
# UI transcript and session persistence.
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
api_messages = agent._drop_thinking_only_and_merge_users(
api_messages,
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
)
# Normalize message whitespace and tool-call JSON for consistent
# prefix matching. Ensures bit-perfect prefixes across turns,
+12 -1
View File
@@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def bare_gemini_model_id(model: str) -> str:
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
name = (model or "").strip()
lowered = name.lower()
for prefix in ("google/", "gemini/"):
if lowered.startswith(prefix):
return name[len(prefix):].strip() or name
return name
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
@@ -330,7 +340,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"parts": [{"text": joined_system}]}
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
return contents, system_instruction
@@ -914,6 +924,7 @@ class GeminiNativeClient:
thinking_config=thinking_config,
)
model = bare_gemini_model_id(model)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)
+60
View File
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
existing_tool_names = {
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
}
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
valid_tool_names = getattr(agent, "valid_tool_names", None)
if valid_tool_names is None:
valid_tool_names = set()
agent.valid_tool_names = valid_tool_names
added = 0
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
existing_tool_names.add(tool_name)
added += 1
return added
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------
+8 -1
View File
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
if "type" in node and node["type"] not in {None, ""}:
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
+14 -5
View File
@@ -508,13 +508,22 @@ PLATFORM_HINTS = {
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram has NO table syntax — prefer bullet lists or labeled "
"key: value pairs over pipe tables (any tables you do emit are "
"auto-rewritten into row-group bullets, which you can produce "
"directly for cleaner output). "
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
+4 -16
View File
@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
prompt_cache_key = kwargs.get("prompt_cache_key")
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:
@@ -3,8 +3,9 @@
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 1. wait for the old Hermes desktop process to fully exit (so both the
//! venv shim and packaged app.asar are free; otherwise `hermes update`
//! or repair bootstrap can race locked files),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
/// How long to wait for the old desktop process to release files under the
/// install tree before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// aborts with exit 2. If it still holds the packaged app.asar,
// install.ps1's repair/re-clone path cannot move/remove the install tree.
// Give both handles a bounded window to clear.
wait_for_install_locks_free(&install_root, &app, "update").await;
// ---- stage 1: hermes update -----------------------------------------
// Pass --branch so `hermes update` targets the branch this installer was
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// already exited and waited for the install locks to clear before launching
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
let lock_targets = install_lock_probe_paths(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
let locked = locked_paths(&lock_targets);
if locked.is_empty() {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
// itself) is still holding one of the update-sensitive files. The
// desktop should have reaped its tree before handing off, but
// SIGTERM races / detached grandchildren / AV handles can leave a
// straggler. Rather than "proceed anyway" straight into uv's
// "Access is denied" or install.ps1's locked app.asar failure,
// force-kill every Hermes.exe except ourselves, then give the OS a
// beat to unload the image.
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] Hermes still holding the venv shim; force-killing stragglers…",
&format!(
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
format_locked_paths(&locked)
),
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
let locked_after_kill = locked_paths(&lock_targets);
if locked_after_kill.is_empty() {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
"[handoff] install files freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
&format!(
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
format_locked_paths(&locked_after_kill)
),
);
}
return;
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
}
}
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
let mut paths = vec![venv_hermes(install_root)];
paths.extend(desktop_app_payload_paths(install_root));
paths
}
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "windows") {
vec![
release.join("win-unpacked").join("resources").join("app.asar"),
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
]
} else if cfg!(target_os = "macos") {
vec![
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
]
} else {
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
}
}
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
paths.iter().filter(|p| is_locked(p)).cloned().collect()
}
fn format_locked_paths(paths: &[PathBuf]) -> String {
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
@@ -891,6 +934,29 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(
probes.iter().any(|p| p == &venv_hermes(root)),
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
#[test]
fn locked_paths_ignores_missing_payloads() {
let root = Path::new("/nonexistent/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(locked_paths(&probes).is_empty());
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(
+46
View File
@@ -1835,6 +1835,44 @@ async function applyUpdates(opts = {}) {
}
}
async function handOffWindowsBootstrapRecovery(reason) {
if (!IS_WINDOWS || !IS_PACKAGED) return false
const updater = resolveUpdaterBinary()
if (!updater) return false
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = directoryExists(path.join(updateRoot, '.git'))
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
: configuredBranch || DEFAULT_UPDATE_BRANCH
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
setTimeout(() => {
app.quit()
}, 600)
return true
}
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
// the install we're updating, fall back to `hermes` on PATH.
function resolveHermesCliBinary(updateRoot) {
@@ -2432,6 +2470,14 @@ async function ensureRuntime(backend) {
if (backend.kind === 'bootstrap-needed') {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
throw handoffError
}
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
+122 -45
View File
@@ -85,6 +85,8 @@ import {
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
@@ -135,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
return 'command'
}
/** A `/` query is at its arg stage once it's past the command name. */
const slashArgStage = (query: string) => query.includes(' ')
/** The `/command` token of a slash query (`personality x` → `/personality`). */
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -532,48 +540,6 @@ export function ChatBar({
})
}, [])
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
document.execCommand('insertText', false, pastedText)
const nextDraft = composerPlainText(event.currentTarget)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
@@ -610,7 +576,15 @@ export function ChatBar({
}
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
const found = detectTrigger(before ?? composerPlainText(editor))
// The arg-stage popover is only useful for commands with an options screen.
// For a no-arg command it would dead-end on "No matches", so drop it — the
// directive is already complete.
const detected =
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
? null
: found
setTrigger(detected)
@@ -650,6 +624,46 @@ export function ChatBar({
flushEditorToDraft(event.currentTarget)
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
insertPlainTextAtCaret(event.currentTarget, pastedText)
flushEditorToDraft(event.currentTarget)
}
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@@ -665,6 +679,12 @@ export function ChatBar({
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
@@ -675,6 +695,25 @@ export function ChatBar({
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
// Commit the literally-typed `/command arg` as a directive chip — used when
// the completion list is empty because the arg is already fully typed (the
// backend completer drops exact matches). Reuses the chip path via a
// synthetic item whose serialized form is the verbatim text.
const commitTypedSlashDirective = () => {
if (trigger?.kind !== '/') {
return
}
const text = `/${trigger.query.trimEnd()}`
replaceTriggerWithChip({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
})
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const editor = editorRef.current
@@ -793,6 +832,18 @@ export function ChatBar({
return
}
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
return
}
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
@@ -822,7 +873,15 @@ export function ChatBar({
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
// Enter / Tab / Space all accept the highlighted item: a no-arg command
// commits its directive chip, an arg-taking command expands to its
// options step, and an arg option commits the full `/cmd arg` chip. Space
// is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
if (accept) {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
@@ -843,6 +902,24 @@ export function ChatBar({
}
}
// Arg stage with nothing left to suggest — a fully-typed arg the backend
// completer no longer echoes (it drops the exact match), e.g.
// `/personality creative`. Space/Tab still commit what's typed as a single
// directive chip; Enter falls through to submit (send it as-is).
if (
trigger?.kind === '/' &&
!triggerItems.length &&
(event.key === ' ' || event.key === 'Tab') &&
slashArgStage(trigger.query) &&
trigger.query.trim()
) {
event.preventDefault()
triggerKeyConsumedRef.current = true
commitTypedSlashDirective()
return
}
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
@@ -1765,7 +1842,7 @@ export function ChatBar({
ref={composerRef}
>
{showHelpHint && <HelpHint />}
{trigger && (
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
@@ -3,12 +3,24 @@ import { describe, expect, it } from 'vitest'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
const caretIn = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
@@ -59,3 +71,64 @@ describe('insertInlineRefsIntoEditor', () => {
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
})
describe('insertPlainTextAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
editor.remove()
})
it('replaces the selected span', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'abXYef'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 2)
range.setEnd(text, 4)
selection.removeAllRanges()
selection.addRange(range)
insertPlainTextAtCaret(editor, 'cd')
expect(composerPlainText(editor)).toBe('abcdef')
editor.remove()
})
})
describe('deleteSelectionInEditor', () => {
it('clears a non-collapsed range and leaves a collapsed caret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hello world'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
expect(deleteSelectionInEditor(editor)).toBe(true)
expect(composerPlainText(editor)).toBe('')
expect(selection.getRangeAt(0).collapsed).toBe(true)
expect(deleteSelectionInEditor(editor)).toBe(false)
editor.remove()
})
})
@@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) {
appendComposerContents(target, text)
}
/** Caret range when the selection lives inside `editor`; else null. */
function composerSelectionRange(editor: HTMLElement) {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
return null
}
return { range, selection }
}
/** Insert plain text at the caret (replacing any selection). Pastes use this
* instead of `execCommand('insertText')` Chromium's editing pipeline is
* ~O(n²) on large multiline blobs. */
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendTextWithBreaks(fragment, text)
const tail = fragment.lastChild
if (hit) {
hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
}
if (tail) {
const caret = document.createRange()
caret.setStartAfter(tail)
caret.collapse(true)
const selection = hit?.selection ?? window.getSelection()
selection?.removeAllRanges()
selection?.addRange(caret)
}
}
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
export function deleteSelectionInEditor(editor: HTMLElement) {
const hit = composerSelectionRange(editor)
if (!hit || hit.range.collapsed) {
return false
}
hit.range.deleteContents()
hit.range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(hit.range)
return true
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0
@@ -284,6 +284,7 @@ export function ProfileRail() {
selectProfile(name)
}}
open={createOpen}
profiles={profiles}
/>
<RenameProfileDialog
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
export function CreateProfileDialog({
onClose,
onCreated,
open
open,
profiles = []
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
profiles?: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setSoul('')
setError(null)
setStatus('idle')
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
@@ -127,7 +138,7 @@ export function CreateProfileDialog({
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>
+31 -20
View File
@@ -12,6 +12,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
async (name: string, cloneFrom: null | string) => {
const trimmed = name.trim()
if (!isValidProfileName(trimmed)) {
throw new Error(p.nameHint)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
notify({ kind: 'success', title: p.created, message: trimmed })
setSelectedName(trimmed)
await refresh()
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
open={createOpen}
profiles={profiles ?? []}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
function CreateProfileDialog({
onClose,
onCreate,
open
open,
profiles
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
open: boolean
profiles: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@@ -472,7 +476,7 @@ function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setError(null)
setSaving(false)
}, [open])
@@ -493,7 +497,7 @@ function CreateProfileDialog({
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
await onCreate(trimmed, cloneFrom)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : p.failedCreate)
@@ -528,18 +532,25 @@ function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">{p.cloneFromDefault}</span>
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
expect($approvalRequest.get()).toBeNull()
})
it('reveals the full command inline when the Command toggle is clicked', () => {
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
setRequest(longCommand)
render(<PendingToolApproval part={part('terminal')} />)
// Collapsed by default: the full command is not in the DOM yet.
expect(screen.queryByText(longCommand)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
expect(screen.getByText(longCommand)).toBeTruthy()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
// The pending tool row only shows a single truncated line of the command, and
// a pending row can't be expanded (no result yet), so the full command was
// previously only reachable via the "Always allow" modal. Let the user reveal
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
const [showCommand, setShowCommand] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const hasCommand = request.command.trim().length > 0
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}, [confirmAlways, respond])
return (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('once')}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasCommand && (
<Button
aria-expanded={showCommand}
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setShowCommand(value => !value)}
size="xs"
variant="ghost"
>
{copy.command}
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
</Button>
)}
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
{showCommand && hasCommand && (
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>
{copy.alwaysDescription(request.description)}
</DialogDescription>
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
</DialogHeader>
{request.command.trim() && (
+4
View File
@@ -903,6 +903,9 @@ export const en: Translations = {
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFrom: 'Clone from',
cloneFromNone: 'None (blank)',
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,
@@ -1687,6 +1690,7 @@ export const en: Translations = {
gatewayDisconnected: 'Hermes gateway is not connected',
sendFailed: 'Could not send approval response',
run: 'Run',
command: 'Command',
moreOptions: 'More approval options',
allowSession: 'Allow this session',
alwaysAllowMenu: 'Always allow…',
+4
View File
@@ -1041,6 +1041,9 @@ export const ja = defineLocale({
deleting: '削除中...',
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
nameLabel: '名前',
cloneFrom: '複製元',
cloneFromNone: 'なし(空)',
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
invalidName: hint => `無効なプロファイル名。${hint}`,
@@ -1827,6 +1830,7 @@ export const ja = defineLocale({
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
sendFailed: '承認応答を送信できませんでした',
run: '実行',
command: 'コマンド',
moreOptions: 'その他の承認オプション',
allowSession: 'このセッションで許可',
alwaysAllowMenu: '常に許可…',
+4
View File
@@ -695,6 +695,9 @@ export interface Translations {
deleting: string
createDesc: string
nameLabel: string
cloneFrom: string
cloneFromNone: string
cloneFromDesc: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string
@@ -1346,6 +1349,7 @@ export interface Translations {
gatewayDisconnected: string
sendFailed: string
run: string
command: string
moreOptions: string
allowSession: string
alwaysAllowMenu: string
+4
View File
@@ -999,6 +999,9 @@ export const zhHant = defineLocale({
deleting: '刪除中…',
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
nameLabel: '名稱',
cloneFrom: '複製來源',
cloneFromNone: '無(空白)',
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
cloneFromDefault: '從預設設定檔複製設定',
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
invalidName: hint => `設定檔名稱無效。${hint}`,
@@ -1771,6 +1774,7 @@ export const zhHant = defineLocale({
gatewayDisconnected: 'Hermes 閘道未連線',
sendFailed: '無法傳送核准回應',
run: '執行',
command: '指令',
moreOptions: '更多核准選項',
allowSession: '允許本工作階段',
alwaysAllowMenu: '一律允許…',
+4
View File
@@ -1092,6 +1092,9 @@ export const zh: Translations = {
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFrom: '克隆来源',
cloneFromNone: '无(空白)',
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,
@@ -1867,6 +1870,7 @@ export const zh: Translations = {
gatewayDisconnected: 'Hermes 网关未连接',
sendFailed: '无法发送审批响应',
run: '运行',
command: '命令',
moreOptions: '更多审批选项',
allowSession: '允许本会话',
alwaysAllowMenu: '始终允许…',
+1 -1
View File
@@ -470,7 +470,7 @@ export interface CronJobUpdates {
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: string
clone_from?: null | string
clone_from_default?: boolean
name: string
no_skills?: boolean
-5
View File
@@ -719,11 +719,6 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# # Bot API 10.1 Rich Messages: final replies send raw markdown via
# # sendRichMessage so tables, task lists, collapsible details, math, etc.
# # render natively (with automatic MarkdownV2 fallback). Opt-in while
# # the new endpoint is validated; default false.
# rich_messages: false # Set true to enable native rich rendering
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+67 -27
View File
@@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
return cleaned
def _estimate_tui_input_height(
lines: list[str] | tuple[str, ...],
prompt_text: str,
terminal_columns: int,
*,
max_height: int = 8,
) -> int:
"""Estimate classic prompt_toolkit input rows using live terminal cells.
The TextArea prompt is injected with prompt_toolkit's BeforeInput
processor, which means it consumes cells only on logical line 0. After a
narrow resize, that first row can leave only one input cell beside an icon
prompt such as `` ``, while continuation rows use the full terminal width.
Never substitute a fake wide fallback here: under- or over-allocating the
TextArea height leaves stale prompt/input cells visible at the bottom of the
terminal.
"""
try:
from prompt_toolkit.utils import get_cwidth
except Exception:
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
try:
columns = int(terminal_columns or 0)
except (TypeError, ValueError):
columns = 0
columns = max(1, columns)
prompt_width = max(0, get_cwidth(prompt_text or ""))
visual_lines = 0
for index, line in enumerate(lines or [""]):
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
# applies only to logical line 0. Wrapped continuation rows, and later
# logical lines, use the full terminal width. Count the display cells
# after that same transformation rather than subtracting the prompt from
# every wrapped row.
line_width = get_cwidth(line or "")
display_width = line_width + (prompt_width if index == 0 else 0)
if display_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-display_width // columns))
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
"""Collect local image attachments for single-query CLI flows."""
message = query or ""
@@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Instead we just reset prompt_toolkit's renderer cache so the next
incremental redraw starts from a clean slate, then let
``original_on_resize`` recalculate layout for the new size.
Let prompt_toolkit's own resize path run with its renderer cursor
cache intact. Its Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which needs the cached
cursor position to move back to the live prompt origin before
erase_down(). Resetting the renderer before that erase loses the
origin and can leave stale prompt glyphs after a narrow resize.
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
status bar and input separator rules stay hidden until the next user
@@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
next prompt restores the bar cleanly.
"""
self._status_bar_suppressed_after_resize = True
try:
app.renderer.reset(leave_alternate_screen=False)
except Exception:
pass
try:
app.invalidate()
except Exception:
pass
original_on_resize()
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
@@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
terminal_columns = get_app().output.get_size().columns
except Exception:
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
# Each logical line takes at least 1 visual row; long lines wrap.
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width)) # ceil division
return min(max(visual_lines, 1), 8)
terminal_columns = shutil.get_terminal_size((80, 24)).columns
return _estimate_tui_input_height(
doc.lines,
self._get_tui_prompt_text(),
terminal_columns,
)
except Exception:
return 1
@@ -12765,6 +12798,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
style=style,
full_screen=False,
mouse_support=False,
# The status bar contains wall-clock read-outs (live prompt elapsed
# and idle-since-last-turn). Once a turn finishes there may be no
# further events to invalidate the app, so prompt_toolkit would keep
# rendering the first post-turn value (usually ``✓ 0s``) forever.
# A low-rate refresh keeps the clock honest without reintroducing a
# custom repaint thread or touching conversation state.
refresh_interval=1.0,
# Erase the live bottom chrome (status bar, input box, separator
# rules) on exit instead of freezing a final copy into scrollback.
# Without this, prompt_toolkit's render_as_done teardown repaints
+2
View File
@@ -85,6 +85,8 @@ Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
# Backward compatibility for older dashboard/desktop clients.
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
+126 -27
View File
@@ -37,10 +37,12 @@ class GatewayAuthorizationMixin:
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
message is dispatched to the gateway. The flag alone is NOT "already
authorized": these adapters default to ``open``, which forwards every
sender, so ``_is_user_authorized`` only trusts the adapter when its
effective policy for the chat type is an actual ``allowlist`` restriction
(see that method). Defaults to ``False`` when the adapter is unknown or
doesn't expose the flag.
"""
if not platform:
return False
@@ -65,10 +67,11 @@ class GatewayAuthorizationMixin:
env var is not always bridged back into ``config.extra``) and falls
back to ``config.extra`` for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
so the gateway can run its pairing handshake, so "reached the gateway"
must not be read as "authorized".
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
actually restricted DM senders to a configured allowlist (trustworthy)
or merely forwarded everyone under ``dm_policy: open`` / for a pairing
handshake (not authorization). "Reached the gateway" only carries an
authorization signal in the ``allowlist`` case.
"""
if not platform:
return ""
@@ -87,6 +90,89 @@ class GatewayAuthorizationMixin:
policy = extra.get("dm_policy")
return str(policy or "").strip().lower()
def _adapter_group_policy(self, platform: Optional[Platform]) -> str:
"""Best-effort read of an own-policy adapter's effective group policy.
Mirror of ``_adapter_dm_policy`` for group / forum / channel traffic:
returns the lowercased ``group_policy`` (``"open"`` / ``"allowlist"`` /
``"disabled"``) for *platform*, or ``""`` when unknown. Prefers the live
adapter's resolved ``_group_policy`` and falls back to ``config.extra``
for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
restricted group senders to a configured allowlist (trustworthy) or
forwarded the whole channel under ``group_policy: open`` (not
authorization).
"""
if not platform:
return ""
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
policy = getattr(adapter, "_group_policy", None) if adapter is not None else None
if policy is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
policy = extra.get("group_policy")
return str(policy or "").strip().lower()
def _adapter_group_has_sender_allowlist(
self,
platform: Optional[Platform],
chat_id: Optional[str],
) -> bool:
"""Whether a per-group sender allowlist gated this group message.
WeCom supports ``groups.<group_id>.allow_from`` on top of the top-level
``group_policy``. A group may be open at the chat level while still
restricting which senders inside that group can invoke Hermes. If such a
message reached the gateway, the adapter already checked that sender
allowlist, so it is a trustworthy intake decision rather than the
fail-open ``group_policy: open`` case.
"""
if not platform or not chat_id:
return False
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
groups = getattr(adapter, "_groups", None) if adapter is not None else None
if groups is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
groups = extra.get("groups")
if not isinstance(groups, dict):
return False
chat_id_str = str(chat_id)
group_cfg = groups.get(chat_id_str)
if not isinstance(group_cfg, dict):
lowered = chat_id_str.lower()
for key, value in groups.items():
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
group_cfg = value
break
if not isinstance(group_cfg, dict):
group_cfg = groups.get("*")
if not isinstance(group_cfg, dict):
return False
sender_allow = group_cfg.get("allow_from") or group_cfg.get("allowFrom")
if isinstance(sender_allow, str):
return bool(sender_allow.strip())
if isinstance(sender_allow, (list, tuple, set)):
return any(str(item).strip() for item in sender_allow)
return False
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
@@ -237,27 +323,40 @@ class GatewayAuthorizationMixin:
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlists configured. Adapters that own their own
# No env allowlist configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) already gated this message at
# intake — it would not have reached the gateway otherwise — so
# honor that decision instead of falling through to the
# env-only default-deny below, which would silently break
# `dm_policy: open` and config-only allowlists. (#34515)
# allow_from / group_allow_from) gate access at intake, so for those
# platforms we can honor the adapter's decision instead of the
# env-only default-deny below -- but ONLY when that decision was an
# actual allowlist restriction.
#
# The adapters default dm_policy / group_policy to "open", which
# forwards EVERY sender. Reading "reached the gateway" as
# authorization in that case would admit the whole external network
# with no operator-configured allowlist -- the fail-open SECURITY.md
# §2.6 forbids ("an allowlist is required for every enabled
# network-exposed adapter ... code paths that fail open when no
# allowlist is configured are code bugs"). "disabled" never
# forwards, and "pairing" forwards unpaired DMs only so the gateway
# can run its pairing handshake (the pairing-store check above
# already denied this sender). So trust the adapter only when its
# effective policy for THIS chat type is "allowlist"; for "open" /
# "pairing" / anything else, fall through to default-deny, where
# GATEWAY_ALLOW_ALL_USERS, the per-platform {PLATFORM}_ALLOW_ALL_USERS
# flag (checked above), and the pairing flow remain the explicit
# opt-ins to broader access. (#34515 follow-up: trusting "open" was a
# fail-open.)
if self._adapter_enforces_own_access_policy(source.platform):
# Exception: `dm_policy: pairing` does NOT authorize at intake.
# The adapter forwards the DM precisely so the gateway can run
# its pairing handshake (issue a code, consult the pairing
# store). The pairing-store approval check above already ran and
# returned False for this sender, so blanket-trusting the
# adapter here would silently turn pairing mode into open
# access. Fall through to default-deny so the unpaired sender is
# offered a pairing code instead. (Pairing is DM-only; group
# traffic keeps the adapter-trust path.)
if not (
source.chat_type == "dm"
and self._adapter_dm_policy(source.platform) == "pairing"
):
if source.chat_type in {"group", "forum", "channel"}:
effective_policy = self._adapter_group_policy(source.platform)
if self._adapter_group_has_sender_allowlist(
source.platform,
source.chat_id,
):
return True
else:
effective_policy = self._adapter_dm_policy(source.platform)
if effective_policy == "allowlist":
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
+56 -8
View File
@@ -1128,8 +1128,11 @@ SUPPORTED_DOCUMENT_TYPES = {
".ini": "text/plain",
".cfg": "text/plain",
".zip": "application/zip",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ts": "text/plain",
".py": "text/plain",
@@ -1913,16 +1916,21 @@ class BasePlatformAdapter(ABC):
enforce it at intake: a message is dropped inside the adapter and never
reaches the gateway unless it already passed that policy.
The gateway's env-based allowlist check runs *after* the adapter, so for
these platforms a message arriving at ``_is_user_authorized`` has, by
definition, already been authorized by the adapter. Without this flag the
gateway would then deny it again (no env allowlist default deny),
silently breaking ``dm_policy: open`` and config-only allowlists.
The gateway's env-based allowlist check runs *after* the adapter. When
no env allowlist is configured, the gateway consults this flag so it can
honor a config-only ``dm_policy: allowlist`` / ``allow_from`` (which the
adapter already enforced) instead of double-denying it. Crucially, the
flag alone is NOT "already authorized": these adapters default
``dm_policy`` / ``group_policy`` to ``"open"``, which forwards every
sender, so the gateway trusts the adapter only when its effective policy
for the chat type is an actual ``"allowlist"`` restriction never for
``"open"`` (that would be the network-exposed fail-open SECURITY.md §2.6
forbids). Open access still requires an explicit
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
Adapters that own their access policy override this to return ``True``.
The gateway treats that as "already authorized at intake" and skips the
env-allowlist default-deny. Adapters that delegate access control to the
gateway leave it ``False`` (the default).
Adapters that delegate access control to the gateway leave it ``False``
(the default).
"""
return False
@@ -1945,6 +1953,46 @@ class BasePlatformAdapter(ABC):
"""
return False
def prefers_fresh_final_streaming(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Whether the stream consumer should finalize a streamed reply by
sending a *fresh* final message (and deleting the preview) instead of
final-editing the preview.
Some adapters can send richer final messages than their current edit
implementation supports. Telegram is the motivating case: Hermes sends
final replies through ``sendRichMessage`` but still finalizes streamed
previews through its existing MarkdownV2 edit path until Bot API 10.1's
``rich_message`` edit parameter is wired directly. Such adapters
override this to ask the consumer to re-deliver the completed answer as
a new rich message and best-effort delete the stale preview, so the
final rendering matches the rich send path.
Default implementation returns False legacy platforms keep the
edit-in-place finalization path.
"""
return False
def streaming_overflow_limit(self) -> Optional[int]:
"""Max single-message length (in this adapter's ``message_len_fn``
units) the stream consumer may accumulate before it splits, when the
adapter can deliver a larger message than its legacy per-message limit.
Telegram Bot API 10.1 Rich Messages accept up to 32,768 chars in a
single ``sendRichMessage`` / ``sendRichMessageDraft``, far above the
4,096 MarkdownV2 limit. Adapters with such a richer send/draft path
override this so the consumer doesn't fragment a reply that fits one
rich message; the live edit preview is still bound by the platform's
edit limit, but the finalized reply (and DM draft preview) is delivered
whole.
Return ``None`` (default) to use ``MAX_MESSAGE_LENGTH``.
"""
return None
async def send_draft(
self,
chat_id: str,
+1 -1
View File
@@ -209,7 +209,7 @@ class _MatrixHtmlSanitizer(HTMLParser):
_ALLOWED_TAGS = {
"a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
"strong", "ul",
"strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
}
_VOID_TAGS = {"br", "hr"}
+77 -26
View File
@@ -349,8 +349,11 @@ class TelegramAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
RICH_MESSAGE_MAX_BYTES = 32768
# UTF-8 characters. Content above this is sent via the legacy chunking path.
RICH_MESSAGE_MAX_CHARS = 32768
# Backwards-compatible alias for tests/external callers that referenced the
# initial implementation name. The API limit is character-based, not bytes.
RICH_MESSAGE_MAX_BYTES = RICH_MESSAGE_MAX_CHARS
# Threshold for detecting Telegram client-side message splits.
# When a chunk is near this limit, a continuation is almost certain.
_SPLIT_THRESHOLD = 4000
@@ -416,10 +419,8 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: opportunistically send final replies via
# sendRichMessage with the raw agent markdown so tables/task lists/etc.
# render natively. Opt-out via platforms.telegram.extra.rich_messages.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
# with the raw agent markdown so tables/task lists/etc. render natively.
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -922,19 +923,20 @@ class TelegramAdapter(BasePlatformAdapter):
# the RAW agent markdown so richer constructs (tables, task lists,
# collapsible details, math, ...) render natively. The legacy MarkdownV2
# send() path stays as the fallback for unsupported/oversized content and
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
# Telegram exposes no rich-edit method.
# older PTB/clients. Streaming edits stay on Hermes' existing MarkdownV2
# edit path for now; finalization can re-send as rich and delete the stale
# preview until rich_message edit support is wired directly.
# ------------------------------------------------------------------
def _content_fits_rich_limits(self, content: str) -> bool:
"""Cheap pre-check for the one hard rich limit we can count locally.
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
not pre-counted; if exceeded Telegram returns a BadRequest, which
:meth:`_is_rich_fallback_error` classifies as permanent so the send
degrades to the legacy chunking path.
"""
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
def _bot_supports_rich(self) -> bool:
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
@@ -948,19 +950,57 @@ class TelegramAdapter(BasePlatformAdapter):
"""
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
def _should_attempt_rich(self, content: str) -> bool:
# getattr defaults: tests build adapters via object.__new__() (no
# __init__), so the flags may be unset — default rich OFF (the
# feature is opt-in via platforms.telegram.extra.rich_messages).
def _should_attempt_rich(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
and content
and content.strip()
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def prefers_fresh_final_streaming(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""Finalize rich-eligible streamed replies with a fresh sendRichMessage
instead of Hermes' current MarkdownV2 edit path.
The final edit path has not yet been upgraded to Bot API 10.1's
``rich_message`` edit parameter, so finalizing through edit would lose
rich constructs such as tables/task lists. When the completed content
is rich-eligible, re-send it via ``sendRichMessage`` and delete the
preview (see ``gateway.stream_consumer._try_fresh_final``).
``metadata`` is intentionally ignored: the preview was sent with
``expect_edits=True`` (to stay on the editable path mid-stream), but the
FINAL answer is a brand-new message that should render rich. Gating
otherwise matches :meth:`_should_attempt_rich`: rich not latched off,
content present and within the rich character limit, and the bot exposes
an async ``do_api_request``.
"""
return self._should_attempt_rich(content)
def streaming_overflow_limit(self) -> Optional[int]:
"""Allow the stream consumer to accumulate up to the rich-message cap
before splitting, so a reply that fits one ``sendRichMessage`` /
``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit.
Gated on the same rich capability as the send path (minus the
content-length check raising that cap is the whole point): rich not
latched off and the bot exposes an async ``do_api_request``. Returns
``None`` ( legacy 4,096 limit) when rich isn't available, so non-rich
streams split exactly as before.
"""
if (
not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
return self.RICH_MESSAGE_MAX_CHARS
return None
def _rich_message_payload(
self, content: str, *, skip_entity_detection: bool = False
) -> Dict[str, Any]:
@@ -982,16 +1022,19 @@ class TelegramAdapter(BasePlatformAdapter):
rejections (BadRequest from a parser/limit issue) are NOT capability
errors: the next message may be fine.
"""
name = exc.__class__.__name__.lower()
if name in {"endpointnotfound", "invalidtoken"}:
return True
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
return True
if getattr(exc, "error_code", None) == 404:
return True
s = str(exc).lower()
if ("method" in s and "not found" in s) or "no such method" in s:
if ("method" in s or "endpoint" in s) and (
"not found" in s or "does not exist" in s
):
return True
if "unsupported" in s or "not implemented" in s:
return True
return False
return "no such method" in s
def _is_rich_fallback_error(self, exc: Exception) -> bool:
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
@@ -1003,7 +1046,10 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if self._is_bad_request_error(exc):
return True
return self._is_rich_capability_error(exc)
if self._is_rich_capability_error(exc):
return True
s = str(exc).lower()
return "unsupported" in s or "not implemented" in s
def _compute_single_send_routing(
self,
@@ -1076,6 +1122,8 @@ class TelegramAdapter(BasePlatformAdapter):
# which must not be sent as a stray field on the raw endpoint.
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
payload.update(self._notification_kwargs(metadata))
if getattr(self, "_disable_link_previews", False):
payload["link_preview_options"] = {"is_disabled": True}
if reply_to_id is not None:
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
# object), NOT the legacy reply_to_message_id scalar. Unknown
@@ -1084,8 +1132,12 @@ class TelegramAdapter(BasePlatformAdapter):
payload["reply_parameters"] = {"message_id": reply_to_id}
try:
# Take the raw Bot API result (dict under real PTB). Passing
# return_type=Message would make PTB deserialize a Bot API 10.1
# response shape it does not fully model yet; a post-delivery parse
# error must not be mistaken for a sendable failure.
msg = await self._bot.do_api_request(
"sendRichMessage", api_kwargs=payload, return_type=Message
"sendRichMessage", api_kwargs=payload
)
except Exception as exc:
if self._is_rich_fallback_error(exc):
@@ -1122,7 +1174,7 @@ class TelegramAdapter(BasePlatformAdapter):
if isinstance(msg, dict):
message_id = msg.get("message_id")
if message_id is None:
message_id = msg.get("result", {}).get("message_id")
message_id = (msg.get("result") or {}).get("message_id")
else:
message_id = getattr(msg, "message_id", None)
return SendResult(
@@ -1132,8 +1184,7 @@ class TelegramAdapter(BasePlatformAdapter):
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
and content.strip()
@@ -2153,7 +2204,7 @@ class TelegramAdapter(BasePlatformAdapter):
# through to the legacy MarkdownV2 path on permanent/capability
# errors or DM-topic routing skips; returns directly on success or
# on a transient failure (which must NOT be legacy-resent).
if self._should_attempt_rich(content):
if self._should_attempt_rich(content, metadata=metadata):
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
if rich_result is not None:
if rich_result.success:
+110 -60
View File
@@ -4556,6 +4556,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
continue
# Claim the session slot *before* spawning the task so that an
# inbound message arriving between task creation and the task's
# first await (where _process_message_background sets the real
# sentinel) sees the slot as occupied and queues behind it
# instead of spinning up a duplicate AIAgent (#45456).
self._running_agents[entry.session_key] = _AGENT_PENDING_SENTINEL
self._running_agents_ts[entry.session_key] = time.time()
# Empty-text internal event — the _is_resume_pending branch in
# _handle_message_with_agent prepends the proper reason-aware
# system note before the turn runs.
@@ -4565,7 +4573,33 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
source=source,
internal=True,
)
task = asyncio.create_task(adapter.handle_message(event))
async def _guarded_handle_message(
_adapter: Any, _event: MessageEvent, _key: str = entry.session_key,
) -> None:
"""Ensure the pre-claimed sentinel is always released.
In the normal flow the resume turn reaches
``_handle_message``, which replaces our pre-claim with
its own ``_AGENT_PENDING_SENTINEL`` (and releases it in
its ``finally`` block) once the run begins. If
``handle_message`` raises *before* the runner takes over
the slot (e.g. during topic recovery or session-key
resolution), nobody clears our pre-claim so we do it
here unconditionally. The ``is _AGENT_PENDING_SENTINEL``
guard below only releases the slot we ourselves placed,
never one a live run currently owns.
"""
try:
await _adapter.handle_message(_event)
finally:
# Only release if the sentinel we set is still there
# (i.e. _process_message_background hasn't replaced
# and cleaned it already).
if self._running_agents.get(_key) is _AGENT_PENDING_SENTINEL:
self._release_running_agent_state(_key)
task = asyncio.create_task(_guarded_handle_message(adapter, event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
scheduled += 1
@@ -8815,12 +8849,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Auto-resetting session %s after compression exhaustion.",
session_entry.session_id,
)
self.session_store.reset_session(session_key)
new_entry = self.session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
if new_entry is not None:
# Drop the stale reference to the bloated compressed child and
# re-point the Telegram topic binding at the fresh session.
# Compression rotated session_entry.session_id to the oversized
# compressed child earlier this turn (the agent-result sync
# above), and that _sync also rewrote the (chat_id, thread_id)
# -> bloated-child binding. reset_session swaps in a clean,
# parentless session, but without re-syncing the binding the
# next inbound message in this topic gets switch_session'd back
# onto the bloated child by the binding-heal walk, reloads the
# oversized transcript, and re-triggers compression exhaustion
# forever (#35809 — regression of the #9893/#10063 auto-reset).
# No-op on non-topic lanes.
session_entry = new_entry
self._sync_telegram_topic_binding(
source, session_entry, reason="compression-exhausted-reset",
)
response = (response or "") + (
"\n\n🔄 Session auto-reset — the conversation exceeded the "
"maximum context size and could not be compressed further. "
@@ -14565,6 +14616,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
_resolved_model = getattr(_agent, "model", None) if _agent else None
# Sync session_id immediately after run_conversation(). Compression
# can rotate before a follow-up model call fails; the failure return
# below must still point the gateway at the compressed child.
agent = agent_holder[0]
_session_was_split = False
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
if agent and session_key and agent_session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent_session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent_session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent_session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent_session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
if entry:
self._sync_telegram_topic_binding(
source, entry, reason="agent-run-compression",
)
effective_session_id = agent_session_id
_effective_history_offset = 0 if _session_was_split else len(agent_history)
if not final_response:
error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
return {
@@ -14579,7 +14685,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"error": result.get("error"),
"compression_exhausted": result.get("compression_exhausted", False),
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"history_offset": _effective_history_offset,
"session_id": effective_session_id,
"last_prompt_tokens": _last_prompt_toks,
"input_tokens": _input_toks,
"output_tokens": _output_toks,
@@ -14625,63 +14732,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
unique_tags.insert(0, "[[audio_as_voice]]")
final_response = final_response + "\n" + "\n".join(unique_tags)
# Sync session_id: the agent may have created a new session during
# mid-run context compression (_compress_context splits sessions).
# If so, update the session store entry so the NEXT message loads
# the compressed transcript, not the stale pre-compression one.
agent = agent_holder[0]
_session_was_split = False
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent.session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent.session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent.session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent.session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
# When compression created a new session, the messages list was
# shortened. Using the original history offset would produce an
# empty new_messages slice, causing the gateway to write only a
# user/assistant pair — losing the compressed summary and tail.
# Reset to 0 so the gateway writes ALL compressed messages.
_effective_history_offset = 0 if _session_was_split else len(agent_history)
# Auto-generate session title after first exchange (non-blocking)
if final_response and self._session_db:
try:
+143 -22
View File
@@ -143,6 +143,13 @@ class GatewayStreamConsumer:
# timestamps would be stale by completion time. Ported from
# openclaw/openclaw#72038.
self._message_created_ts: Optional[float] = None
# Every real preview message id the consumer has put on screen during
# this response (first send + any continuation messages from oversized
# edits/sends). The fresh-final path deletes all of them when it
# re-delivers the completed answer as a single (rich) message, so a
# reply that was split across the platform's edit limit while streaming
# doesn't leave stale fragments above the final message.
self._preview_message_ids: "set[str]" = set()
self._already_sent = False
self._edit_supported = True # Disabled when progressive edits are no longer usable
self._last_edit_time = 0.0
@@ -420,7 +427,10 @@ class GatewayStreamConsumer:
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
_raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
# Rich-capable adapters (Telegram rich messages) raise this above the
# legacy per-message limit so a reply that fits one rich send/draft
# isn't fragmented at 4096 while streaming. See _raw_message_limit.
_raw_limit = self._raw_message_limit()
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
# Resolve native draft streaming once per run. When enabled the
@@ -589,9 +599,20 @@ class GatewayStreamConsumer:
if self._accumulated:
if self._fallback_final_send:
await self._send_fallback_final(self._accumulated)
elif current_update_visible and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
elif self._final_response_sent:
# A finalize=True tick above already delivered the
# final answer via the adapter's fresh-final path
# (_try_fresh_final sent a fresh rich message and
# deleted the preview). Running a second finalize
# edit here would duplicate the message / re-delete,
# so just record delivery and stop.
self._final_content_delivered = True
elif (
current_update_visible
and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
)
):
# Mid-stream edit above already delivered the
# final accumulated content. Skip the redundant
@@ -729,6 +750,9 @@ class GatewayStreamConsumer:
return reply_to_id
try:
meta = dict(self.metadata) if self.metadata else {}
# This chunk becomes the next edit target — adapters that support
# rich final sends (Telegram) must keep it on the editable path.
meta["expect_edits"] = True
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
@@ -737,6 +761,7 @@ class GatewayStreamConsumer:
)
if result.success and result.message_id:
self._message_id = str(result.message_id)
self._track_preview_ids_from_result(result)
self._already_sent = True
self._last_sent_text = text
# Fresh content bubble — close off any stale tool bubble
@@ -1114,6 +1139,76 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
def _raw_message_limit(self) -> int:
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
before the consumer splits an overflowing reply.
Adapters with a richer send/draft path (e.g. Telegram rich messages)
can raise this above ``MAX_MESSAGE_LENGTH`` via
``streaming_overflow_limit`` so a reply that fits one rich message isn't
fragmented at the legacy edit limit. Falls back to
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
"""
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
# isinstance gate: MagicMock adapters return mock objects (truthy, not
# ints) for arbitrary attribute access — keep them on the base limit.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
cap = self.adapter.streaming_overflow_limit()
except Exception as e:
logger.debug("streaming_overflow_limit check failed: %s", e)
cap = None
if isinstance(cap, int) and cap > base:
return cap
return base
def _track_preview_id(self, message_id: Optional[str]) -> None:
"""Record a real preview message id for fresh-final cleanup."""
if message_id and message_id != "__no_edit__":
self._preview_message_ids.add(str(message_id))
def _track_preview_ids_from_result(self, result: Any) -> None:
"""Record every message id a send/edit result exposes: the primary id
plus any continuation ids from an oversized split
(``continuation_message_ids`` or ``raw_response['message_ids']``)."""
self._track_preview_id(getattr(result, "message_id", None))
for mid in (getattr(result, "continuation_message_ids", None) or ()):
self._track_preview_id(mid)
raw = getattr(result, "raw_response", None) or {}
if isinstance(raw, dict):
for mid in (raw.get("message_ids") or ()):
self._track_preview_id(mid)
def _adapter_prefers_fresh_final(self, text: str) -> bool:
"""Return True when the adapter would rather finalize a streamed reply
by sending a fresh message and deleting the preview than by editing the
preview in place e.g. Telegram, whose ``sendRichMessage`` send path
currently renders richer markdown than Hermes' MarkdownV2 edit path.
Returns False when there is no real preview to replace (no message id,
or the ``__no_edit__`` sentinel), when the adapter doesn't expose the
hook, or on any error (the consumer then keeps the edit-in-place path).
"""
if not self._message_id or self._message_id == "__no_edit__":
return False
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
if fn is None:
return False
try:
try:
result = fn(text, metadata=self.metadata)
except TypeError:
# Adapter / test double whose hook doesn't accept the metadata
# keyword — fall back to the positional-only form.
result = fn(text)
except Exception as e:
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
return False
# ``is True`` (not ``bool(...)``) so a MagicMock adapter's auto-child
# method — truthy by default in tests — does not wrongly enable the
# fresh-final path. Mirrors the REQUIRES_EDIT_FINALIZE gate in __init__.
return result is True
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
@@ -1127,7 +1222,13 @@ class GatewayStreamConsumer:
Ported from openclaw/openclaw#72038.
"""
old_message_id = self._message_id
# Every preview message the user has seen for this response: the
# current one plus any continuation fragments tracked while streaming
# (an oversized reply split across the platform's edit limit). All of
# them are replaced by the single fresh message below.
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
try:
result = await self.adapter.send(
chat_id=self.chat_id,
@@ -1139,25 +1240,29 @@ class GatewayStreamConsumer:
return False
if not getattr(result, "success", False):
return False
# Successful fresh send — try to delete the stale preview so the
# user doesn't see the old edit-stuck message underneath. Cleanup
# is best-effort; platforms that don't implement ``delete_message``
# just leave the preview behind (still an acceptable outcome —
# the visible final timestamp is the important part).
if old_message_id and old_message_id != "__no_edit__":
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
try:
await delete_fn(self.chat_id, old_message_id)
except Exception as e:
logger.debug(
"Fresh-final preview cleanup failed (%s): %s",
old_message_id, e,
)
# Adopt the new message id as the current message so subsequent
# callers (e.g. overflow split loops, finalize retries) see a
# consistent state.
new_message_id = getattr(result, "message_id", None)
# Successful fresh send — try to delete the stale preview(s) so the
# user doesn't see the old edit-stuck message(s) underneath. Cleanup
# is best-effort; platforms that don't implement ``delete_message``
# just leave the preview behind (still an acceptable outcome — the
# visible final timestamp is the important part). Never delete the
# message we just sent.
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Fresh-final preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
if new_message_id:
self._message_id = new_message_id
self._message_created_ts = time.monotonic()
@@ -1267,9 +1372,19 @@ class GatewayStreamConsumer:
# old preview follows. Ported from
# openclaw/openclaw#72038. Gated by config so the
# legacy edit-in-place path stays the default.
#
# Adapters can also opt in regardless of the time threshold
# via prefers_fresh_final_streaming (e.g. Telegram, whose
# send path renders richer markdown than its edit path):
# finalizing through edit would visibly downgrade a rich
# preview, so re-deliver as a fresh message + delete the
# preview instead.
if (
finalize
and self._should_send_fresh_final()
and (
self._should_send_fresh_final()
or self._adapter_prefers_fresh_final(text)
)
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
@@ -1283,6 +1398,9 @@ class GatewayStreamConsumer:
)
if result.success:
self._already_sent = True
# Record any continuation fragments an oversized edit
# split off, so fresh-final can clean them all up.
self._track_preview_ids_from_result(result)
# Adapter may have split-and-delivered an oversized
# edit across the original message + N continuations.
# When that happens, ``message_id`` is the LAST visible
@@ -1405,7 +1523,7 @@ class GatewayStreamConsumer:
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id,
metadata=self.metadata,
metadata={**(self.metadata or {}), "expect_edits": True},
)
if result.success:
if result.message_id:
@@ -1414,6 +1532,9 @@ class GatewayStreamConsumer:
# the user so fresh-final logic can detect stale
# preview timestamps on long-running responses.
self._message_created_ts = time.monotonic()
# Record this (and any continuation fragments from an
# oversized first send) for fresh-final cleanup.
self._track_preview_ids_from_result(result)
else:
self._edit_supported = False
self._already_sent = True
+69 -7
View File
@@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
_save_auth_store(auth_store)
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
imported = _import_codex_cli_tokens()
# Require BOTH tokens before adopting: persisting a payload without a
# usable refresh_token would only break the next refresh cycle.
if not (
imported
and str(imported.get("access_token", "") or "").strip()
and str(imported.get("refresh_token", "") or "").strip()
):
return None
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
_save_codex_tokens(imported)
return dict(imported)
def refresh_codex_oauth_pure(
access_token: str,
refresh_token: str,
@@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(
Saves the new tokens to Hermes auth store automatically.
"""
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
try:
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
except AuthError as exc:
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
# Codex OAuth token (per profile + top-level), separate from the Codex
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
# the Codex CLI (or another Hermes process) rotates the shared token,
# this frozen copy's refresh_token goes stale and the refresh fails with
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
# Before surfacing that as a hard 401 to the turn, adopt the canonical
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
# idle profiles / desktop sessions recover automatically instead of
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
# keep relogin_required=False — the stored token is still valid there, so
# we never self-heal those and re-raise unchanged.
if not getattr(exc, "relogin_required", False):
raise
imported = _recover_codex_tokens_from_cli(
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
)
if not imported:
raise
return imported
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
@@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
credential. See issue #32992.
"""
read_error: Optional[AuthError] = None
try:
data = _read_codex_tokens()
except AuthError:
except AuthError as exc:
read_error = exc
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
"codex_auth_missing_access_token",
"codex_auth_missing_refresh_token",
"codex_auth_invalid_shape",
}:
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
if imported:
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
else:
data = None
else:
data = None
if data is None:
pool_token = _pool_codex_access_token()
if pool_token:
base_url = (
@@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
raise
if read_error is not None:
raise read_error
raise AuthError(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
+29 -3
View File
@@ -1543,8 +1543,14 @@ def run_doctor(args):
total = critical + high + moderate
# Determine a scoped fix command for the remediation hint.
if audit_extra and audit_extra[0] == "--workspace":
fix_scope = " ".join(audit_extra)
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
# Detection (`npm audit --workspace <name>`) is read-only and
# safe, but `npm audit fix --workspace <name>` crashes on
# current npm with "Cannot read properties of null (reading
# 'edgesOut')" — an arborist bug with workspace-filtered
# audit fix. The root-level `npm audit fix` can crash on the
# same tree with "isDescendantOf", so do not hand the user a
# manual fix command for these build-tool advisories.
fix_cmd = None
elif audit_extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
@@ -1552,10 +1558,30 @@ def run_doctor(args):
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
if fix_cmd:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
)
else:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — "
"build-tool advisory; clears via lockfile bump"
)
check_warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
f"({vuln_detail})"
)
if audit_extra and audit_extra[0] == "--workspace":
# The web/ui-tui workspace advisories are in build-time
# tooling (esbuild/vite, etc.), not runtime code that ships
# to users. Manual npm remediation may error with a known
# arborist crash (edgesOut / isDescendantOf) on this monorepo
# tree — in that case it is an npm bug, not a Hermes one.
check_info(
" ^ build-time tooling (not runtime); if manual npm remediation "
"errors with an arborist crash it's a known npm bug — clears "
"via a lockfile bump"
)
issues.append(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
+37
View File
@@ -6675,6 +6675,40 @@ def _worker_terminal_timeout_env(
return str(desired)
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
"""Return the assigned profile's effective CLI toolsets for a worker.
Dispatcher-spawned workers are launched from a long-lived gateway process,
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
assignee profile's CLI tool surface at dispatch time and pass it as an
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
root/active-profile config or a profile whose top-level ``toolsets`` entry
is only the kanban orchestrator surface. ``model_tools`` still appends the
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
"""
if not hermes_home:
return None
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
token = set_hermes_home_override(hermes_home)
try:
cfg = load_config()
toolsets = sorted(_get_platform_tools(cfg, "cli"))
finally:
reset_hermes_home_override(token)
return toolsets or None
except Exception as exc:
_log.debug(
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
hermes_home,
exc,
)
return None
def _default_spawn(
task: Task,
workspace: str,
@@ -6808,6 +6842,9 @@ def _default_spawn(
cmd.extend(["--skills", sk])
if task.model_override:
cmd.extend(["-m", task.model_override])
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
if worker_toolsets:
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
cmd.extend([
"chat",
"-q", prompt,
+201 -9
View File
@@ -8030,6 +8030,182 @@ def _run_pre_update_backup(args) -> None:
print()
def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool:
"""Write a planned-stop marker into a specific profile home."""
try:
from datetime import timezone
from gateway.status import _get_process_start_time
from utils import atomic_json_write
record = {
"target_pid": pid,
"target_start_time": _get_process_start_time(pid),
"stopper_pid": os.getpid(),
"written_at": datetime.now(timezone.utc).isoformat(),
}
atomic_json_write(
Path(profile_path) / ".gateway-planned-stop.json",
record,
indent=None,
separators=(",", ":"),
)
return True
except (OSError, PermissionError):
return False
def _wait_for_windows_update_gateway_exit(
pids: list[int], *, timeout: float
) -> set[int]:
"""Wait for the given gateway PIDs to exit, returning survivors."""
if not pids:
return set()
from gateway.status import _pid_exists
remaining = set(pids)
deadline = _time.monotonic() + max(timeout, 0.0)
while remaining and _time.monotonic() < deadline:
for pid in list(remaining):
try:
if not _pid_exists(pid):
remaining.discard(pid)
except Exception:
remaining.discard(pid)
if remaining:
_time.sleep(0.25)
survivors: set[int] = set()
for pid in remaining:
try:
if _pid_exists(pid):
survivors.add(pid)
except Exception:
pass
return survivors
def _pause_windows_gateways_for_update() -> dict | None:
"""Stop running Windows gateways before mutating the checkout or venv.
Windows scheduled/startup gateways run through pythonw.exe, so the generic
hermes.exe concurrent-instance guard does not see them. They still import
from the checkout and can keep files locked while ``git`` or ``uv`` updates
the install. Stop only PIDs that the gateway discovery code identifies.
"""
if not _is_windows():
return None
try:
from gateway.status import terminate_pid
from hermes_cli.gateway import (
_get_restart_drain_timeout,
find_gateway_pids,
find_profile_gateway_processes,
)
except Exception as exc:
logger.debug("Could not prepare Windows gateway pause for update: %s", exc)
return None
try:
running_pids = list(dict.fromkeys(find_gateway_pids(all_profiles=True)))
except Exception as exc:
logger.debug("Could not discover Windows gateway PIDs before update: %s", exc)
return None
if not running_pids:
return None
profile_processes = {}
try:
profile_processes = {
proc.pid: proc for proc in find_profile_gateway_processes()
}
except Exception as exc:
logger.debug("Could not map Windows gateway PIDs to profiles: %s", exc)
profiles: dict[str, int] = {}
mapped_pids = []
for pid in running_pids:
proc = profile_processes.get(pid)
if proc is None:
continue
profiles[str(proc.profile)] = int(pid)
mapped_pids.append(int(pid))
_write_update_planned_stop_marker(Path(proc.path), int(pid))
print("→ Stopping Windows gateway process(es) before updating Hermes...")
try:
drain_timeout = max(float(_get_restart_drain_timeout()), 1.0)
except Exception:
drain_timeout = 10.0
survivors = _wait_for_windows_update_gateway_exit(
mapped_pids,
timeout=drain_timeout,
)
unmapped_pids = [pid for pid in running_pids if pid not in profile_processes]
force_killed = []
for pid in sorted(set(survivors).union(unmapped_pids)):
try:
terminate_pid(int(pid), force=True)
force_killed.append(int(pid))
except (ProcessLookupError, PermissionError, OSError):
pass
if profiles:
print(f" ✓ Paused gateway profile(s): {', '.join(sorted(profiles))}")
if force_killed:
print(f" → Force-stopped {len(force_killed)} gateway process(es)")
if unmapped_pids:
print(
f" → Stopped {len(unmapped_pids)} gateway process(es) without profile mapping"
)
print(" Restart manually after update: hermes gateway run")
return {
"resume_needed": True,
"profiles": profiles,
"unmapped_pids": unmapped_pids,
}
def _resume_windows_gateways_after_update(token: dict | None) -> None:
"""Restart Windows profile gateways previously paused for update."""
if not token or not token.get("resume_needed"):
return
token["resume_needed"] = False
if not _is_windows():
return
profiles = token.get("profiles") or {}
if not profiles:
return
try:
from hermes_cli.gateway import launch_detached_profile_gateway_restart
except Exception as exc:
logger.debug("Could not load Windows gateway restart helper: %s", exc)
return
relaunched = []
for profile, old_pid in sorted(profiles.items()):
try:
if launch_detached_profile_gateway_restart(str(profile), int(old_pid)):
relaunched.append(str(profile))
except Exception as exc:
logger.debug(
"Could not restart Windows gateway profile %s after update: %s",
profile,
exc,
)
if relaunched:
print()
print(f" ✓ Restarting Windows gateway profile(s): {', '.join(relaunched)}")
def _discard_lockfile_churn(git_cmd, repo_root):
"""Restore tracked ``package-lock.json`` files that npm dirtied locally.
@@ -8232,6 +8408,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
_windows_gateway_resume = _pause_windows_gateways_for_update()
if _windows_gateway_resume:
import atexit as _atexit
_atexit.register(
_resume_windows_gateways_after_update,
_windows_gateway_resume,
)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
use_zip_update = False
@@ -8294,7 +8479,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
if use_zip_update:
# ZIP-based update for Windows when git is broken
_update_via_zip(args)
try:
_update_via_zip(args)
finally:
_resume_windows_gateways_after_update(_windows_gateway_resume)
return
# Fetch and pull
@@ -8431,6 +8619,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
check=False,
)
print("✓ Already up to date!")
_resume_windows_gateways_after_update(_windows_gateway_resume)
return
print(f"→ Found {commit_count} new commit(s)")
@@ -9627,6 +9816,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Gateway restart during update failed: %s", e)
_resume_windows_gateways_after_update(_windows_gateway_resume)
# Warn if legacy Hermes gateway unit files are still installed.
# When both hermes.service (from a pre-rename install) and the
# current hermes-gateway.service are enabled, they SIGTERM-fight
@@ -9860,19 +10051,20 @@ def cmd_profile(args):
try:
clone_from = getattr(args, "clone_from", None)
clone_config = clone or clone_from is not None
profile_dir = create_profile(
name=name,
clone_from=clone_from,
clone_all=clone_all,
clone_config=clone,
clone_config=clone_config,
no_alias=no_alias,
no_skills=no_skills,
description=getattr(args, "description", None),
)
print(f"\nProfile '{name}' created at {profile_dir}")
if clone or clone_all:
if clone_config or clone_all:
source_label = (
getattr(args, "clone_from", None) or get_active_profile_name()
)
@@ -9886,8 +10078,8 @@ def cmd_profile(args):
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
)
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
if clone or clone_all:
# Auto-clone Honcho config for the new profile (only with clone operations)
if clone_config or clone_all:
try:
from plugins.memory.honcho.cli import clone_honcho_for_profile
@@ -9896,10 +10088,10 @@ def cmd_profile(args):
except Exception:
pass # Honcho plugin not installed or not configured
# Seed bundled skills (skip if --clone-all already copied them, or
# if --no-skills was passed — in which case seed_profile_skills()
# honors the marker file and returns skipped_opt_out=True).
if not clone_all:
# Seed bundled skills for fresh profiles only. Clone operations
# already copied the source profile's skills, including any
# user-installed or intentionally removed skills.
if not (clone_config or clone_all):
result = seed_profile_skills(profile_dir)
if result and result.get("skipped_opt_out"):
print(
+2 -1
View File
@@ -84,7 +84,6 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
# Providers whose native naming is authoritative -- pass through unchanged.
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
"gemini",
"huggingface",
})
@@ -103,6 +102,8 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
"arcee",
"ollama-cloud",
"custom",
"gemini",
"xai",
})
# Providers whose APIs require lowercase model IDs. Xiaomi's
+63
View File
@@ -26,6 +26,7 @@ from dataclasses import dataclass
from typing import List, NamedTuple, Optional
from hermes_cli.providers import (
ProviderDef,
custom_provider_slug,
determine_api_mode,
get_label,
@@ -46,6 +47,23 @@ from agent.models_dev import (
logger = logging.getLogger(__name__)
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
base_url = str(current_base_url or "").strip()
if not base_url:
return None
return ProviderDef(
id="custom",
name="Custom endpoint",
transport="openai_chat",
api_key_env_vars=(),
base_url=base_url,
is_aggregator=False,
auth_type="api_key",
source="model-config",
)
# ---------------------------------------------------------------------------
# Non-agentic model warning
# ---------------------------------------------------------------------------
@@ -676,6 +694,8 @@ def switch_model(
user_providers,
custom_providers,
)
if pdef is None and explicit_provider.strip().lower() == "custom":
pdef = _bare_custom_provider_def(current_base_url)
if pdef is None:
_switch_err = (
f"Unknown provider '{explicit_provider}'. "
@@ -881,6 +901,8 @@ def switch_model(
provider_changed = target_provider != current_provider
provider_label = get_label(target_provider)
if target_provider == "custom" and current_base_url:
provider_label = "Custom endpoint"
if target_provider.startswith("custom:"):
custom_pdef = resolve_provider_full(
target_provider,
@@ -932,6 +954,10 @@ def switch_model(
api_key = _ukey
base_url = _user_pdef.base_url
api_mode = ""
elif target_provider == "custom" and current_base_url:
api_key = current_api_key
base_url = current_base_url
api_mode = determine_api_mode(target_provider, base_url)
else:
try:
runtime = resolve_runtime_provider(
@@ -1748,6 +1774,43 @@ def list_authenticated_providers(
if _pair[0] and _pair[1]:
_section3_emitted_pairs.add(_pair)
# --- 3b. Active bare custom endpoint from model config ---
# A config can still use the direct one-off form:
# model.provider: custom
# model.base_url: https://some-openai-compatible/v1
# In that shape there is no named providers:/custom_providers row for the
# picker to render, but the gateway only passes this current model slice to
# list_authenticated_providers(). Surface the active endpoint explicitly so
# /model does not look like it ignored config.yaml.
_current_provider_norm = str(current_provider or "").strip().lower()
if (
_current_provider_norm == "custom"
and current_base_url
and "custom" not in seen_slugs
and not any(
isinstance(_cp, dict)
and str(
_cp.get("base_url", "")
or _cp.get("url", "")
or _cp.get("api", "")
).strip().rstrip("/").lower()
== str(current_base_url).strip().rstrip("/").lower()
for _cp in (custom_providers or [])
)
):
_models = [current_model] if current_model else []
results.append({
"slug": "custom",
"name": "Custom endpoint",
"is_current": True,
"is_user_defined": True,
"models": _models[:max_models] if max_models else _models,
"total_models": len(_models),
"source": "model-config",
"api_url": str(current_base_url).strip().rstrip("/"),
})
seen_slugs.add("custom")
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same endpoint, credential identity, and
+27
View File
@@ -135,6 +135,20 @@ def _sanitize_plugin_name(
return target
_GITHUB_BROWSER_SEGMENTS = {
"actions",
"blob",
"commit",
"commits",
"issues",
"pull",
"pulls",
"releases",
"tree",
"wiki",
}
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
@@ -146,6 +160,8 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
- Full URL: https://github.com/owner/repo.git
- Full URL: git@github.com:owner/repo.git
- Full URL: ssh://git@github.com/owner/repo.git
- Browser URL: https://github.com/owner/repo/tree/main/path
(https://github.com/owner/repo.git, "path")
- Shorthand: owner/repo https://github.com/owner/repo.git
- Shorthand w/ subdir: owner/repo/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
@@ -161,6 +177,17 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""
# Already a URL.
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
if identifier.startswith("https://github.com/"):
path = identifier[len("https://github.com/") :]
path = path.split("?", 1)[0].split("#", 1)[0].strip("/")
parts = path.split("/")
if len(parts) >= 3 and all(parts[:2]) and parts[2] in _GITHUB_BROWSER_SEGMENTS:
repo = parts[1].removesuffix(".git")
subdir = None
if parts[2] == "tree" and len(parts) >= 5:
subdir = "/".join(p for p in parts[4:] if p).strip("/") or None
return f"https://github.com/{parts[0]}/{repo}.git", subdir
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
if "#" in identifier:
git_url, _, frag = identifier.partition("#")
+12 -6
View File
@@ -784,9 +784,9 @@ def create_profile(
Path
The newly created profile directory.
"""
if no_skills and (clone_config or clone_all):
if no_skills and (clone_from is not None or clone_config or clone_all):
raise ValueError(
"--no-skills is mutually exclusive with --clone / --clone-all "
"--no-skills is mutually exclusive with --clone / --clone-from / --clone-all "
"(cloning explicitly copies skills from the source profile)."
)
canon = normalize_profile_name(name)
@@ -1190,10 +1190,16 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
can re-register manually later via the gateway start command,
which goes through the same dispatch path.
Port selection is governed by the profile's ``config.yaml``
(``[gateway] port = ``) there is no Python-side allocator
(PR #30136 review item I5 retired the SHA-256-derived range
[9200, 9800) because it was dead code through the entire stack).
Port selection: each supervised profile gateway loads its own
``HERMES_HOME`` and binds the port resolved by ``gateway/config.py``
from that profile's environment — ``API_SERVER_PORT`` (or
``platforms.api_server.extra.port`` in the profile's
``config.yaml``), defaulting to 8642. There is no ``[gateway] port``
key and no Python-side allocator (PR #30136 review item I5 retired
the SHA-256-derived range [9200, 9800) as dead code), so two
profiles that both leave the port at its default will both try to
bind 8642 give each profile a distinct ``API_SERVER_PORT`` in its
``.env``.
Host short-circuit: check ``detect_service_manager()`` first and
return immediately if it isn't ``"s6"``. This keeps host
+55
View File
@@ -660,6 +660,61 @@ def has_named_custom_provider(requested_provider: str) -> bool:
return False
def find_custom_provider_identity(base_url: str) -> Optional[str]:
"""Map an endpoint URL back to its canonical ``custom:<name>`` menu key.
Returns the ``custom:<normalized-name>`` slug of the first ``providers:``
/ ``custom_providers:`` entry whose base_url matches, or ``None`` when no
entry owns the URL.
Session persistence stores the agent's *resolved* provider, and for every
named custom endpoint that is the literal string ``"custom"`` the entry
name is lost, and the api_key is deliberately never persisted. The
endpoint URL is the one durable fact that survives the round-trip, so
this reverse lookup lets persist/rebuild paths recover the entry identity
(and with it key_env/api_key/api_mode resolution via
:func:`_get_named_custom_provider`) instead of failing with
``auth_unavailable`` or silently rebuilding with placeholder credentials.
"""
target = _normalize_base_url_for_match(base_url)
if not target:
return None
try:
config = load_config()
except Exception:
return None
providers = config.get("providers")
if isinstance(providers, dict):
for ep_name, entry in providers.items():
if not isinstance(entry, dict):
continue
entry_url = (
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
)
if _normalize_base_url_for_match(entry_url) == target:
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
try:
custom_providers = get_compatible_custom_providers(config)
except Exception:
custom_providers = None
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
if _normalize_base_url_for_match(entry.get("base_url")) == target:
return f"custom:{_normalize_custom_provider_name(name)}"
return None
def _normalize_base_url_for_match(value) -> str:
return str(value or "").strip().rstrip("/").lower()
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
extra_body = custom_provider.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:
+14 -9
View File
@@ -585,15 +585,20 @@ class S6ServiceManager:
would instead look up ``$HERMES_HOME/profiles/default/`` a
completely different (and almost always nonexistent) profile.
Port selection: the gateway picks its bind port from the
profile's ``config.yaml`` (``[gateway] port = ...``) — that
is the single source of truth. Previously this method took a
``port`` parameter that was passed in but never substituted
into the rendered script (it was carried in for "API parity"
with a deterministic SHA-256 allocator in
``hermes_cli.profiles._allocate_gateway_port``). PR #30136
review item I5 retired both the allocator and the parameter
because they were dead code through the entire stack.
Port selection: the gateway binds the port resolved by
``gateway/config.py`` from the profile's own environment —
``API_SERVER_PORT`` (or ``platforms.api_server.extra.port`` in
that profile's ``config.yaml``), defaulting to 8642. There is
no ``[gateway] port`` key and no Python-side allocator: because
each supervised profile gateway loads its own ``HERMES_HOME``,
two profiles that both leave the port unset will both try to
bind 8642 give each profile a distinct ``API_SERVER_PORT`` in
its ``.env``. Previously this method took a ``port`` parameter
that was passed in but never substituted into the rendered
script (carried for "API parity" with a deterministic SHA-256
allocator in ``hermes_cli.profiles._allocate_gateway_port``).
PR #30136 review item I5 retired both the allocator and the
parameter because they were dead code through the entire stack.
"""
import shlex
lines = [
+18
View File
@@ -14,6 +14,21 @@ from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
Gateway service lifecycle commands operate on the gateway process, not a
single messaging adapter. Photon briefly printed a per-platform start
command during setup; keep that command parseable so users following the
old hint don't get blocked by argparse before the gateway can start.
"""
parser.add_argument(
"--platform",
dest="platform",
help=argparse.SUPPRESS,
)
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
# =========================================================================
@@ -75,6 +90,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Kill ALL stale gateway processes across all profiles before starting",
)
_add_compat_platform_flag(gateway_start)
# gateway stop
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
@@ -103,6 +119,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Kill ALL gateway processes across all profiles before restarting",
)
_add_compat_platform_flag(gateway_restart)
# gateway status
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
@@ -118,6 +135,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Target the Linux system-level gateway service",
)
_add_compat_platform_flag(gateway_status)
# gateway install
gateway_install = gateway_subparsers.add_parser(
+3 -3
View File
@@ -35,17 +35,17 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
profile_create.add_argument(
"--clone",
action="store_true",
help="Copy config.yaml, .env, SOUL.md from active profile",
help="Copy config.yaml, .env, SOUL.md, and skills from active profile",
)
profile_create.add_argument(
"--clone-all",
action="store_true",
help="Full copy of active profile (all state)",
help="Full copy of active profile (all state, excluding per-profile history)",
)
profile_create.add_argument(
"--clone-from",
metavar="SOURCE",
help="Source profile to clone from (default: active)",
help="Source profile to clone from; implies --clone unless --clone-all is set",
)
profile_create.add_argument(
"--no-alias", action="store_true", help="Skip wrapper script creation"
+140 -47
View File
@@ -1230,7 +1230,12 @@ def _managed_files_policy(request: Request, *, create_root: bool = True) -> Mana
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
# Remote/OAuth access does not imply a hosted container. Users can expose a
# local dashboard through the auth gate (for example a macOS launchd install)
# and still expect the Files page to browse their local home directory. Lock
# to /opt/data only when the installation's Hermes root is actually /opt/data
# (the container/hosted layout) or when HERMES_DASHBOARD_FILES_ROOT is set.
if _default_hermes_root_is_opt_data():
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
@@ -1640,17 +1645,16 @@ async def get_status():
# Module not importable yet (early startup) — leave as [].
pass
return {
# Always-public liveness + auth-gate shape. Safe for external uptime
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
status = {
"version": __version__,
"release_date": __release_date__,
"hermes_home": str(get_hermes_home()),
"config_path": str(get_config_path()),
"env_path": str(get_env_path()),
"config_version": current_ver,
"latest_config_version": latest_ver,
"gateway_running": gateway_running,
"gateway_pid": gateway_pid,
"gateway_health_url": _GATEWAY_HEALTH_URL,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
@@ -1660,6 +1664,27 @@ async def get_status():
"auth_providers": auth_providers,
}
# Absolute host paths, the gateway PID, and the internal gateway health
# URL are deployment recon a liveness probe never needs. ``/api/status``
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
# network-exposed (gated) bind that means *any* unauthenticated caller
# reaches it, and leaking host metadata there contradicts the allowlist's
# own contract ("version, gateway state, active session count, and the
# dashboard auth-gate shape. No bodies, no session content, no secrets").
# Surface this detail only on a loopback / ``--insecure`` bind, where the
# dashboard is local-only and the caller is already inside the trust
# envelope — the same loopback/gated split ``should_require_auth`` draws.
if not auth_required:
status.update({
"hermes_home": str(get_hermes_home()),
"config_path": str(get_config_path()),
"env_path": str(get_env_path()),
"gateway_pid": gateway_pid,
"gateway_health_url": _GATEWAY_HEALTH_URL,
})
return status
_WINDOWS_11_MIN_BUILD = 22000
@@ -2535,6 +2560,7 @@ async def get_sessions(
order: str = "created",
source: str = None,
exclude_sources: str = None,
profile: Optional[str] = None,
):
"""List sessions.
@@ -2558,9 +2584,11 @@ async def get_sessions(
status_code=400,
detail="order must be one of: created, recent",
)
profile_name: Optional[str] = None
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
@@ -2594,11 +2622,16 @@ async def get_sessions(
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
if profile_name:
s["profile"] = profile_name
s["is_default_profile"] = profile_name == "default"
# SQLite stores the flag as 0/1; expose a real JSON boolean.
s["archived"] = bool(s.get("archived"))
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@@ -2724,7 +2757,7 @@ async def get_profiles_sessions(
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
"""Search sessions by ID plus full-text message content using FTS5.
Direct session-id matches are surfaced first, then FTS message-content
@@ -2738,8 +2771,7 @@ async def search_sessions(q: str = "", limit: int = 20):
if not q or not q.strip():
return {"results": []}
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
safe_limit = max(1, min(int(limit or 20), 100))
@@ -2881,6 +2913,8 @@ async def search_sessions(q: str = "", limit: int = 20):
return {"results": list(seen.values())}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions/search failed")
raise HTTPException(status_code=500, detail="Search failed")
@@ -6290,6 +6324,7 @@ def _session_latest_descendant(session_id: str):
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
profile: Optional[str] = None
@app.post("/api/sessions/bulk-delete")
@@ -6334,8 +6369,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(body.profile)
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
@@ -6344,15 +6378,14 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
return {"count": db.count_empty_sessions()}
finally:
@@ -6360,7 +6393,7 @@ async def count_empty_sessions_endpoint():
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
@@ -6379,8 +6412,7 @@ async def delete_empty_sessions_endpoint():
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
@@ -6389,15 +6421,13 @@ async def delete_empty_sessions_endpoint():
@app.get("/api/sessions/stats")
async def get_session_stats():
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
@@ -6535,11 +6565,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
@@ -6555,6 +6583,7 @@ async def export_session_endpoint(session_id: str):
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
profile: Optional[str] = None
@app.post("/api/sessions/prune")
@@ -6562,11 +6591,10 @@ async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile)
try:
sessions_dir = get_hermes_home() / "sessions"
sessions_dir = profile_home / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
@@ -8490,15 +8518,13 @@ async def scan_skill_hub(identifier: str = ""):
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
# Backward compatibility for older dashboard/desktop clients. New clients
# send clone_from="default" (or another profile name) explicitly.
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
# Explicit source profile to clone from (e.g. duplicating an existing
# profile). When set, it takes precedence over ``clone_from_default``,
# which always sources from "default". ``clone_all`` still selects a full
# state copytree vs. a config/skills/SOUL copy.
clone_from: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# Profile-builder additions — all optional, all applied best-effort AFTER
@@ -8770,10 +8796,16 @@ async def create_profile_endpoint(body: ProfileCreate):
clone = True
clone_from = explicit_source
clone_config = not body.clone_all
elif body.clone_all:
# Preserve the dashboard's historical clone-all behavior: a full-copy
# request with no explicit dropdown source copies from default.
clone = True
clone_from = "default"
clone_config = False
else:
clone = body.clone_from_default or body.clone_all
clone = body.clone_from_default
clone_from = "default" if clone else None
clone_config = body.clone_from_default and not body.clone_all
clone_config = clone
try:
path = profiles_mod.create_profile(
name=body.name,
@@ -9612,11 +9644,10 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
@app.get("/api/analytics/usage")
async def get_usage_analytics(days: int = 30):
from hermes_state import SessionDB
async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
from agent.insights import InsightsEngine
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
@@ -9681,15 +9712,13 @@ async def get_usage_analytics(days: int = 30):
@app.get("/api/analytics/models")
async def get_models_analytics(days: int = 30):
async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
"""Rich per-model analytics for the Models dashboard page.
Returns token/cost/session breakdown per model plus capability metadata
from models.dev (context window, vision, tools, reasoning, etc.).
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)
@@ -9711,7 +9740,71 @@ async def get_models_analytics(days: int = 30):
GROUP BY model, billing_provider
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
rows = [dict(r) for r in cur.fetchall()]
raw_rows = [dict(r) for r in cur.fetchall()]
# Session rows can be created before the first billable provider call
# finishes. If that early row records only the model name, and a later
# row for the same model has real accounting + billing_provider, the
# Models page used to show a duplicate "0 tokens / — API calls" card
# next to the real provider card. Fold those session-only rows into
# the single accounted provider row when the ownership is unambiguous.
rows_by_model: Dict[str, List[Dict[str, Any]]] = {}
for row in raw_rows:
rows_by_model.setdefault(row.get("model") or "", []).append(row)
rows: List[Dict[str, Any]] = []
for model_rows in rows_by_model.values():
provider_rows = [r for r in model_rows if r.get("billing_provider")]
if len(provider_rows) == 1:
target = provider_rows[0]
for row in model_rows:
if row is target or row.get("billing_provider"):
continue
has_usage = any(
(row.get(key) or 0) != 0
for key in (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"reasoning_tokens",
"estimated_cost",
"actual_cost",
"api_calls",
"tool_calls",
)
)
if has_usage:
continue
target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0)
target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0)
total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0)
sessions = target.get("sessions") or 0
target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0
rows.append(target)
rows.extend(
r for r in model_rows
if r is not target
and (r.get("billing_provider") or any(
(r.get(key) or 0) != 0
for key in (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"reasoning_tokens",
"estimated_cost",
"actual_cost",
"api_calls",
"tool_calls",
)
))
)
else:
rows.extend(model_rows)
rows.sort(
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
reverse=True,
)
models = []
for row in rows:
+1 -1
View File
@@ -46,7 +46,7 @@ talks to it over loopback.
hermes photon setup --phone +15551234567
# Start the gateway
hermes gateway start --platform photon
hermes gateway start
```
`hermes photon setup` does, in order:
+1 -1
View File
@@ -274,7 +274,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print()
print("✓ Photon setup complete.")
print(" Start the gateway: hermes gateway start --platform photon")
print(" Start the gateway: hermes gateway start")
return 0
+21 -2
View File
@@ -3246,7 +3246,11 @@ class AIAgent:
return sanitize_api_messages(messages)
@staticmethod
def _is_thinking_only_assistant(msg: Dict[str, Any]) -> bool:
def _is_thinking_only_assistant(
msg: Dict[str, Any],
*,
drop_codex_reasoning_items: bool = True,
) -> bool:
"""Return True if ``msg`` is an assistant turn whose only payload is reasoning.
"Thinking-only" means the model emitted reasoning (``reasoning`` or
@@ -3297,15 +3301,30 @@ class AIAgent:
rd = msg.get("reasoning_details")
if isinstance(rd, list) and rd:
return True
# Codex Responses stores encrypted reasoning state under a separate
# assistant-message key. Treat only real reasoning items as
# thinking-only; empty/junk lists should fall through to the generic
# empty-turn handling instead of being dropped here.
codex_items = msg.get("codex_reasoning_items")
if drop_codex_reasoning_items and isinstance(codex_items, list):
return any(
isinstance(item, dict) and item.get("type") == "reasoning"
for item in codex_items
)
return False
@staticmethod
def _drop_thinking_only_and_merge_users(
messages: List[Dict[str, Any]],
*,
drop_codex_reasoning_items: bool = True,
) -> List[Dict[str, Any]]:
"""Forwarder — see ``agent.agent_runtime_helpers.drop_thinking_only_and_merge_users``."""
from agent.agent_runtime_helpers import drop_thinking_only_and_merge_users
return drop_thinking_only_and_merge_users(messages)
return drop_thinking_only_and_merge_users(
messages,
drop_codex_reasoning_items=drop_codex_reasoning_items,
)
@staticmethod
def _cap_delegate_task_calls(tool_calls: list) -> list:
+14
View File
@@ -1171,6 +1171,20 @@ function Install-Repository {
# agent-created dirs (e.g. tinker-atropos/) survive too.
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the following `git checkout`
# aborts with "you need to resolve your current index first"
# -- the GUI "git checkout main failed (exit 1)" install
# failure. Clear the conflict markers with `git reset` first:
# working-tree changes are kept (and stashed just below); only
# the index conflict state is dropped. Mirrors the `hermes
# update` path (#4735).
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
Write-Info "Clearing unmerged index entries from a previous conflict..."
git -c windows.appendAtomically=false reset -q 2>$null
}
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
Write-Info "Local changes detected, stashing before update..."
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
+13
View File
@@ -1111,6 +1111,19 @@ clone_repo() {
local autostash_ref=""
if [ -n "$(git status --porcelain)" ]; then
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the later `git checkout` aborts
# with "you need to resolve your current index first", failing
# the whole install at the repository stage. Clear the conflict
# markers with `git reset` first -- this keeps working-tree
# changes (they're still stashed just below) and only drops the
# index-level conflict state. Mirrors the `hermes update` path
# (#4735).
if [ -n "$(git ls-files --unmerged)" ]; then
log_info "Clearing unmerged index entries from a previous conflict..."
git reset -q
fi
local stash_name
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
log_info "Local changes detected, stashing before update..."
+10
View File
@@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"kenmege@yahoo.com": "Kenmege",
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
"zhang.hz6666@gmail.com": "HaozheZhang6",
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
"tomasz.panek@gmail.com": "tomekpanek",
@@ -78,6 +81,10 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"achaljhawar03@gmail.com": "achaljhawar",
"claytonchew@ClaytonMacMiniM4.local": "claytonchew",
"hbentel@gmail.com": "hbentel",
"JustinBao@outlook.com": "justinbao19",
"kdunn926@gmail.com": "kdunn926",
"mvanhorn@MacBook-Pro.local": "mvanhorn",
"470766206@qq.com": "youjunxiaji",
@@ -489,6 +496,7 @@ AUTHOR_MAP = {
"20nik.nosov21@gmail.com": "nik1t7n",
"90299797+nik1t7n@users.noreply.github.com": "nik1t7n",
"suncokret@protonmail.com": "suncokret12",
"WompaJango@protonmail.com": "WompaJango",
"mio.imoto.ai@gmail.com": "mioimotoai-lgtm",
"aamirjawaid@microsoft.com": "heyitsaamir",
"johnnncenaaa77@gmail.com": "johnncenae",
@@ -984,6 +992,7 @@ AUTHOR_MAP = {
"tuancanhnguyen706@gmail.com": "xxxigm",
"larcombe.n@gmail.com": "NickLarcombe",
"54813621+xxxigm@users.noreply.github.com": "xxxigm",
"xxxigm@users.noreply.github.com": "xxxigm",
"asurla@nvidia.com": "anniesurla",
"kchantharuan@nvidia.com": "nv-kasikritc",
"bbednarski@nvidia.com": "bbednarski9",
@@ -1522,6 +1531,7 @@ AUTHOR_MAP = {
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
"desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages)
"bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API)
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
}
+21 -2
View File
@@ -1797,6 +1797,11 @@ class TestRegisterSessionMcpServers:
state.agent.tools = []
state.agent.valid_tool_names = set()
state.agent._cached_system_prompt = "old prompt"
state.agent._memory_manager = SimpleNamespace(
get_all_tool_schemas=lambda: [
{"name": "hindsight_recall", "description": "Recall", "parameters": {}}
]
)
server = McpServerStdio(
name="srv",
@@ -1807,6 +1812,7 @@ class TestRegisterSessionMcpServers:
fake_tools = [
{"function": {"name": "mcp_srv_search"}},
{"function": {"name": "memory"}},
{"function": {"name": "terminal"}},
]
@@ -1820,8 +1826,21 @@ class TestRegisterSessionMcpServers:
quiet_mode=True,
)
assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
assert state.agent.tools == fake_tools
assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
assert state.agent.tools is fake_tools
assert state.agent.tools[-1] == {
"type": "function",
"function": {
"name": "hindsight_recall",
"description": "Recall",
"parameters": {},
},
}
assert state.agent.valid_tool_names == {
"hindsight_recall",
"memory",
"mcp_srv_search",
"terminal",
}
# _invalidate_system_prompt should have been called
state.agent._invalidate_system_prompt.assert_called_once()
+41
View File
@@ -146,6 +146,47 @@ class TestBuildCallKwargsMaxTokens:
assert "max_completion_tokens" not in kwargs
class TestNousTagsScoping:
def test_tags_injected_when_provider_is_nous(self, monkeypatch):
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "auxiliary_is_nous", False)
kwargs = aux._build_call_kwargs(
provider="nous",
model="hermes-4",
messages=[{"role": "user", "content": "hi"}],
)
assert kwargs["extra_body"]["tags"] == aux._nous_portal_tags()
def test_tags_not_injected_for_gemini_when_main_is_nous(self, monkeypatch):
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "auxiliary_is_nous", True)
kwargs = aux._build_call_kwargs(
provider="gemini",
model="gemini-2.5-flash",
messages=[{"role": "user", "content": "hi"}],
)
assert "extra_body" not in kwargs
def test_tags_not_injected_for_openrouter_when_main_is_nous(self, monkeypatch):
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "auxiliary_is_nous", True)
kwargs = aux._build_call_kwargs(
provider="openrouter",
model="openai/gpt-5.4",
messages=[{"role": "user", "content": "hi"}],
)
assert "extra_body" not in kwargs
class TestNormalizeAuxProvider:
def test_maps_github_copilot_aliases(self):
assert _normalize_aux_provider("github") == "copilot"
+29
View File
@@ -161,6 +161,35 @@ class TestResolveAutoMainFirst:
assert mock_resolve.call_args.args[0] == "anthropic"
assert mock_resolve.call_args.args[1] == "runtime-model"
def test_runtime_base_url_passed_for_named_api_key_provider(self):
"""Named API-key providers inherit the live session endpoint for aux work."""
token_plan_url = "https://token-plan-sgp.xiaomimimo.com/v1"
with patch(
"agent.auxiliary_client._read_main_provider",
return_value="openrouter",
), patch(
"agent.auxiliary_client._read_main_model", return_value="config-model",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_resolve.return_value = (MagicMock(), "mimo-v2.5-pro")
from agent.auxiliary_client import _resolve_auto
_resolve_auto(main_runtime={
"provider": "xiaomi",
"model": "mimo-v2.5-pro",
"base_url": token_plan_url,
"api_key": "tp-test-key",
"api_mode": "chat_completions",
})
assert mock_resolve.call_args.args[0] == "xiaomi"
assert mock_resolve.call_args.args[1] == "mimo-v2.5-pro"
assert mock_resolve.call_args.kwargs["explicit_base_url"] == token_plan_url
assert mock_resolve.call_args.kwargs["explicit_api_key"] == "tp-test-key"
assert mock_resolve.call_args.kwargs["api_mode"] == "chat_completions"
# ── Vision — resolve_vision_provider_client ─────────────────────────────────
+68
View File
@@ -605,6 +605,74 @@ class TestBuildConverseKwargs:
assert kwargs["inferenceConfig"]["temperature"] == 0.7
assert kwargs["inferenceConfig"]["topP"] == 0.9
def test_omits_sampling_params_for_bedrock_opus_4_7(self):
from agent.bedrock_adapter import build_converse_kwargs
for model_id in (
"anthropic.claude-opus-4-7-20260101-v1:0",
"us.anthropic.claude-opus-4-7",
):
kwargs = build_converse_kwargs(
model=model_id,
messages=[{"role": "user", "content": "Hi"}],
temperature=0.7,
top_p=0.9,
)
assert "temperature" not in kwargs["inferenceConfig"]
assert "topP" not in kwargs["inferenceConfig"]
def test_omits_sampling_params_for_bedrock_opus_4_8_variants(self):
from agent.bedrock_adapter import build_converse_kwargs
for model_id in (
"anthropic.claude-opus-4-8-20270101-v1:0",
"us.anthropic.claude-opus-4-8",
"anthropic.claude-opus-4.8",
):
kwargs = build_converse_kwargs(
model=model_id,
messages=[{"role": "user", "content": "Hi"}],
temperature=0.5,
top_p=0.95,
)
assert "temperature" not in kwargs["inferenceConfig"]
assert "topP" not in kwargs["inferenceConfig"]
def test_keeps_sampling_params_for_bedrock_non_restricted_models(self):
from agent.bedrock_adapter import build_converse_kwargs
for model_id in (
"anthropic.claude-sonnet-4-6-20250514-v1:0",
"anthropic.claude-haiku-4-5",
"test-model",
):
kwargs = build_converse_kwargs(
model=model_id,
messages=[{"role": "user", "content": "Hi"}],
temperature=0.7,
top_p=0.9,
)
assert kwargs["inferenceConfig"].get("temperature") == 0.7
assert kwargs["inferenceConfig"].get("topP") == 0.9
def test_bedrock_opus_strips_sampling_params_but_keeps_stop_sequences(self):
from agent.bedrock_adapter import build_converse_kwargs
kwargs = build_converse_kwargs(
model="us.anthropic.claude-opus-4-8",
messages=[{"role": "user", "content": "Hi"}],
temperature=0.7,
top_p=0.9,
stop_sequences=["END"],
)
assert "temperature" not in kwargs["inferenceConfig"]
assert "topP" not in kwargs["inferenceConfig"]
assert kwargs["inferenceConfig"]["stopSequences"] == ["END"]
def test_includes_guardrail_config(self):
from agent.bedrock_adapter import build_converse_kwargs
guardrail = {
+58
View File
@@ -198,6 +198,45 @@ def test_native_client_uses_x_goog_api_key_and_native_models_endpoint(monkeypatc
assert response.choices[0].message.content == "hello"
@pytest.mark.parametrize("model, expected", [
("google/gemini-2.0-flash", "gemini-2.0-flash"),
("gemini/gemini-3-pro-preview", "gemini-3-pro-preview"),
("Google/Gemini-2.5-Pro", "Gemini-2.5-Pro"),
("models/gemini-x", "models/gemini-x"),
("tunedModels/my-tune", "tunedModels/my-tune"),
])
def test_bare_gemini_model_id_strips_only_self_prefix(model, expected):
from agent.gemini_native_adapter import bare_gemini_model_id
assert bare_gemini_model_id(model) == expected
def test_native_client_strips_self_prefix_from_model_url(monkeypatch):
from agent.gemini_native_adapter import GeminiNativeClient
recorded = {}
class DummyHTTP:
def post(self, url, json=None, headers=None, timeout=None):
recorded["url"] = url
return DummyResponse(payload={
"candidates": [{"content": {"parts": [{"text": "ok"}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2},
})
def close(self):
return None
monkeypatch.setattr("agent.gemini_native_adapter.httpx.Client", lambda *a, **k: DummyHTTP())
client = GeminiNativeClient(api_key="AIza-test", base_url="https://generativelanguage.googleapis.com/v1beta")
client.chat.completions.create(
model="google/gemini-2.0-flash",
messages=[{"role": "user", "content": "Hello"}],
)
assert recorded["url"] == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
def test_native_http_error_keeps_status_and_retry_after():
from agent.gemini_native_adapter import gemini_http_error
@@ -328,6 +367,25 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
def test_system_instruction_includes_role_field_and_stays_out_of_contents():
from agent.gemini_native_adapter import build_gemini_request
request = build_gemini_request(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
],
tools=[],
tool_choice=None,
)
assert request["systemInstruction"] == {
"role": "system",
"parts": [{"text": "You are a helpful assistant."}],
}
assert all(content.get("role") != "system" for content in request["contents"])
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
"""max_tokens=None must send the model's full output ceiling, not omit it.
+22 -27
View File
@@ -2,10 +2,11 @@
import json
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock
from agent.memory_provider import MemoryProvider
from agent.memory_manager import MemoryManager
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
# ---------------------------------------------------------------------------
# Concrete test provider
@@ -1320,38 +1321,25 @@ class TestMemoryToolToolsetGate:
causing 10x latency on local models (Qwen3-30B: 1.7s 42s) and
tool-call loops on small models.
These tests mirror the gate logic in agent/agent_init.py around the
memory provider tool injection block. The gate condition is:
These tests exercise the shared gate used by agent init and ACP refreshes.
The gate condition is:
enabled_toolsets is None no filter, inject (backward compat)
"memory" in enabled_toolsets user opted in, inject
selected toolsets include memory user opted in, inject
otherwise (incl. []) skip injection
"""
@staticmethod
def _run_memory_injection(enabled_toolsets, memory_manager):
"""Simulate the gated memory-tool injection block from agent_init.py."""
tools = []
valid_tool_names = set()
if memory_manager and tools is not None and (
enabled_toolsets is None or "memory" in enabled_toolsets
):
_existing = {
t.get("function", {}).get("name")
for t in tools
if isinstance(t, dict)
}
for _schema in memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing:
continue
tools.append({"type": "function", "function": _schema})
if _tname:
valid_tool_names.add(_tname)
_existing.add(_tname)
return tools, valid_tool_names
"""Run the shared memory-tool injection helper against a fake agent."""
fake_agent = SimpleNamespace(
_memory_manager=memory_manager,
enabled_toolsets=enabled_toolsets,
tools=[],
valid_tool_names=set(),
)
inject_memory_provider_tools(fake_agent)
return fake_agent.tools, fake_agent.valid_tool_names
def _mgr_with_tools(self, *tool_names):
"""Build a MemoryManager whose providers expose the named tool schemas."""
@@ -1376,6 +1364,13 @@ class TestMemoryToolToolsetGate:
tools, names = self._run_memory_injection(["terminal", "memory", "web"], mgr)
assert "fact_store" in names
def test_composite_toolset_with_memory_injects(self):
"""Composite toolsets that include memory should inject provider tools."""
mgr = self._mgr_with_tools("hindsight_recall")
tools, names = self._run_memory_injection(["hermes-acp"], mgr)
assert "hindsight_recall" in names
assert any(t["function"]["name"] == "hindsight_recall" for t in tools)
def test_empty_toolsets_blocks_injection(self):
"""`platform_toolsets: telegram: []` must suppress memory tools. (#5544)"""
mgr = self._mgr_with_tools("fact_store")
@@ -1384,7 +1379,7 @@ class TestMemoryToolToolsetGate:
assert names == set()
def test_toolsets_without_memory_blocks_injection(self):
"""Toolset list that doesn't name 'memory' must suppress injection."""
"""Toolsets that don't include memory must suppress injection."""
mgr = self._mgr_with_tools("fact_store")
tools, names = self._run_memory_injection(["terminal", "web"], mgr)
assert tools == []
+49
View File
@@ -397,3 +397,52 @@ class TestEnumNullStripping:
assert db_type["type"] == "string"
assert db_type["enum"] == ["mysql", "postgresql"], \
"null/empty enum values must be stripped after anyOf collapse"
class TestUnionTypeList:
"""Moonshot sanitizer accepts JSON Schema union type arrays."""
def test_union_type_list_normalizes_to_first_concrete_type(self):
params = {
"type": "object",
"properties": {
"limit": {
"type": ["number", "string"],
"description": "Max results",
},
},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["properties"]["limit"]["type"] == "number"
def test_union_type_list_skips_null_type(self):
params = {
"type": "object",
"properties": {
"name": {"type": ["null", "string"]},
},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["properties"]["name"]["type"] == "string"
def test_union_type_list_with_enum_does_not_crash_or_mutate_input(self):
params = {
"type": "object",
"properties": {
"sort": {
"type": ["string", "null"],
"enum": ["asc", "desc", None, ""],
},
},
}
out = sanitize_moonshot_tool_parameters(params)
sort = out["properties"]["sort"]
assert sort["type"] == "string"
assert sort["enum"] == ["asc", "desc"]
assert params["properties"]["sort"]["type"] == ["string", "null"]
+17
View File
@@ -877,6 +877,23 @@ class TestPromptBuilderConstants:
# check that this test is calibrated correctly).
assert "include MEDIA:" in PLATFORM_HINTS["telegram"]
def test_telegram_hint_encourages_rich_markdown(self):
# Telegram Bot API 10.1 rich messages are default-on, so the hint must
# encourage native structured markdown instead of forbidding tables.
hint = PLATFORM_HINTS["telegram"]
lowered = hint.lower()
assert "Telegram has NO table syntax" not in hint
assert "rich markdown" in lowered
assert "table" in lowered
assert "task list" in lowered
assert "math" in lowered
# Hint should proactively steer toward structured formatting, not just
# permit it: bullet + numbered lists for scannable, structured output.
assert "bullet" in lowered
assert "numbered" in lowered
# Local media delivery guidance must remain intact.
assert "include MEDIA:" in hint
def test_platform_hints_mattermost(self):
hint = PLATFORM_HINTS["mattermost"]
assert "Mattermost" in hint
@@ -155,6 +155,46 @@ class TestCodexBuildKwargs:
)
assert "max_output_tokens" not in kw
def test_codex_backend_does_not_set_extra_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gpt-5.4",
messages=messages,
tools=[],
session_id="conv-codex-1",
is_codex_backend=True,
)
assert "extra_headers" not in kw
def test_codex_backend_strips_caller_extra_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gpt-5.4",
messages=messages,
tools=[],
session_id="conv-codex-1",
is_codex_backend=True,
request_overrides={"extra_headers": {"x-test": "1"}},
)
assert "extra_headers" not in kw
def test_non_codex_responses_preserves_caller_extra_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gpt-5.4",
messages=messages,
tools=[],
is_codex_backend=False,
request_overrides={"extra_headers": {"x-test": "1"}},
)
assert kw["extra_headers"] == {"x-test": "1"}
def test_xai_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
+10 -16
View File
@@ -71,18 +71,14 @@ class TestForceFullRedraw:
"invalidate",
]
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
"""Resize recovery must NOT erase screen or scrollback.
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
The startup banner lives in normal terminal scrollback (printed
before prompt_toolkit owns the chrome). Clearing scrollback on
SIGWINCH removes it and ``_replay_output_history`` cannot
reconstruct it. The fix is to only reset the renderer cache and
let ``original_on_resize`` recalculate layout.
Additionally, ``_status_bar_suppressed_after_resize`` must be set
so the input rules and status bar hide until the next user input,
preventing duplicated-bar artifacts on column shrink (#19280).
prompt_toolkit's built-in Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which uses the renderer's
cached cursor position to move back to the live prompt origin before
erase_down(). If Hermes resets the renderer first, that cursor position
is lost and stale prompt glyphs can remain after a narrow resize.
"""
app = MagicMock()
events = []
@@ -94,11 +90,9 @@ class TestForceFullRedraw:
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._recover_after_resize(app, original_on_resize)
assert events == [
"renderer_reset",
"invalidate",
"original_resize",
]
assert events == ["original_resize"]
app.renderer.reset.assert_not_called()
app.invalidate.assert_not_called()
# Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()
+17 -83
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_mod
from cli import HermesCLI
@@ -104,91 +105,24 @@ class TestCLIStatusBar:
assert "-1" not in text
assert "0/200K" in text
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
# is inserted by BeforeInput only on logical line 0. At three terminal
# cells, "⚔ " leaves one cell for the first input character, but
# wrapped continuation rows use the full three cells. Estimating every
# wrapped row as one-cell wide over-allocates the TextArea and can leave
# stale prompt/input cells visible after resize.
assert cli_mod._estimate_tui_input_height(["abcdef"], "", 3) == 3
def test_input_height_counts_wide_characters_using_cell_width(self):
cli_obj = _make_cli()
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
# which wraps to two rows at 14 terminal columns.
assert cli_mod._estimate_tui_input_height(["" * 10], " ", 14) == 2
class _Doc:
lines = ["" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value=" "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app):
assert _input_height() == 2
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
cli_obj = _make_cli()
class _Doc:
lines = ["" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value=" "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
patch("shutil.get_terminal_size") as mock_shutil:
assert _input_height() == 2
mock_shutil.assert_not_called()
def test_input_height_clamps_zero_width_to_one_cell(self):
# Some terminals briefly report zero columns during resize. Treat that
# as a one-cell terminal rather than falling back to a fake wide width.
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
def test_build_status_bar_text_no_cost_in_status_bar(self):
cli_obj = _attach_agent(
@@ -0,0 +1,196 @@
"""Regression tests for #35809 — compression-exhaustion auto-reset loop.
After compression is exhausted the gateway auto-resets the session so the
next message starts on a fresh, empty conversation (#9893 / #10063). That
guarantee regressed once the Telegram topic-binding heal landed
(#20470 / #29712 / #33414):
1. Compression rotates ``session_entry.session_id`` to an oversized
compressed *child* session mid-turn and the agent-result sync rewrites
the ``(chat_id, thread_id) -> child`` topic binding.
2. ``reset_session`` swaps in a clean, parentless session but its return
value was discarded and the topic binding was left pointing at the
bloated child.
3. On the next inbound message in that topic, the binding-heal walk
``switch_session``'d the freshly-reset lane *back* onto the bloated
child, ``load_transcript`` reloaded the oversized transcript, and
compression exhaustion re-fired a new session id every loop.
The fix captures the fresh entry from ``reset_session`` and re-syncs the
topic binding to it (a no-op on non-topic lanes).
Two tests:
* ``TestAutoResetBlockReSyncsBinding`` an AST invariant on
``gateway/run.py`` (mirrors ``test_compression_session_id_persistence.py``):
the compression-exhausted auto-reset block must capture
``reset_session(...)`` and call ``_sync_telegram_topic_binding`` afterward.
This is the load-bearing regression pin.
* ``TestAutoResetLoadsCleanContext`` a behavioral contract on the real
``SessionStore``: after ``reset_session`` the next turn loads an EMPTY
transcript for the new session_id, never the bloated child's transcript.
"""
from __future__ import annotations
import ast
import inspect
from gateway import run as gateway_run
from gateway.config import GatewayConfig, Platform
from gateway.session import SessionSource, SessionStore
from hermes_state import SessionDB
# ---------------------------------------------------------------------------
# AST invariant: the auto-reset block re-syncs the topic binding
# ---------------------------------------------------------------------------
def _find_compression_exhausted_reset_block() -> ast.If:
"""Return the ``if agent_result.get('compression_exhausted') ...`` block."""
tree = ast.parse(inspect.getsource(gateway_run))
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
consts = [
n.value
for n in ast.walk(node.test)
if isinstance(n, ast.Constant) and isinstance(n.value, str)
]
# Identify the auto-reset branch by the literal passed to .get(...).
if "compression_exhausted" in consts:
# Only the branch that actually performs the reset, not the
# earlier classifier that merely reads the flag into a bool.
calls = {
sub.func.attr
for sub in ast.walk(node)
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
}
if "reset_session" in calls:
return node
raise AssertionError(
"Could not locate the compression-exhausted auto-reset block "
"(if agent_result.get('compression_exhausted') ... reset_session) "
"in gateway/run.py — the structure changed or the AST walker is stale."
)
class TestAutoResetBlockReSyncsBinding:
def test_reset_session_return_is_captured(self):
"""``reset_session`` must be assigned, not called-and-discarded —
the fresh entry is needed to re-point the binding and drop the stale
reference to the bloated compressed child (#35809)."""
block = _find_compression_exhausted_reset_block()
captured = False
for stmt in ast.walk(block):
if isinstance(stmt, ast.Assign):
val = stmt.value
if (
isinstance(val, ast.Call)
and isinstance(val.func, ast.Attribute)
and val.func.attr == "reset_session"
):
captured = True
assert captured, (
"gateway/run.py auto-reset block calls reset_session() but discards "
"its return value. The fresh SessionEntry must be captured so the "
"topic binding can be re-pointed at it; otherwise the next message "
"resolves back to the bloated compressed child (#35809)."
)
def test_topic_binding_is_resynced_after_reset(self):
"""The block must re-sync the topic binding so the next inbound message
cannot ``switch_session`` back onto the bloated compressed child."""
block = _find_compression_exhausted_reset_block()
sync_calls = [
sub
for sub in ast.walk(block)
if isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "_sync_telegram_topic_binding"
]
assert sync_calls, (
"gateway/run.py auto-reset block does not call "
"_sync_telegram_topic_binding after reset_session. Without it the "
"(chat_id, thread_id) -> bloated-child binding survives the reset "
"and the binding-heal walk re-anchors the fresh lane onto the "
"oversized compressed transcript, re-triggering the loop (#35809)."
)
# ---------------------------------------------------------------------------
# Behavioral contract: reset yields a clean next-turn transcript
# ---------------------------------------------------------------------------
def _make_store(tmp_path):
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
# Isolate the SQLite transcript store so we exercise per-session_id
# transcripts without touching the developer's real state.db.
store._db = SessionDB(db_path=tmp_path / "state.db")
return store
def _make_source():
return SessionSource(platform=Platform.TELEGRAM, chat_id="123", user_id="u1")
def _bloat(n):
# Stand-in for the oversized, post-compression "child" transcript that
# could not be compressed any further (#35809).
return [{"role": "user", "content": "x" * 2000} for _ in range(n)]
class TestAutoResetLoadsCleanContext:
"""#35809: after the gateway auto-resets a session because compression
was exhausted, the NEXT turn must load an EMPTY transcript for the new
session_id never the bloated compressed-child transcript."""
def test_next_turn_transcript_is_empty_after_auto_reset(self, tmp_path):
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
session_key = entry.session_key
bloated_sid = entry.session_id
store._db.create_session(
session_id=bloated_sid, source="telegram", user_id="u1"
)
store._db.replace_messages(bloated_sid, _bloat(120))
assert len(store.load_transcript(bloated_sid)) == 120 # precondition
new_entry = store.reset_session(session_key)
assert new_entry is not None
assert new_entry.session_id != bloated_sid
resolved = store.get_or_create_session(source)
assert resolved.session_id == new_entry.session_id
loaded = store.load_transcript(resolved.session_id)
assert loaded == [], (
f"Auto-reset must yield an empty context, got {len(loaded)} "
f"messages — the bloated compressed child leaked into the new session."
)
# The old transcript is still searchable, not destroyed.
assert len(store.load_transcript(bloated_sid)) == 120
def test_clean_context_survives_gateway_restart(self, tmp_path):
"""The fresh, empty session must still be the one loaded after a
gateway restart (sessions.json + state.db round-trip)."""
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
bloated_sid = entry.session_id
store._db.create_session(
session_id=bloated_sid, source="telegram", user_id="u1"
)
store._db.replace_messages(bloated_sid, _bloat(120))
new_entry = store.reset_session(entry.session_key)
new_sid = new_entry.session_id
# Simulate restart: drop in-memory index, reload from disk.
store._loaded = False
store._entries.clear()
reloaded = store.get_or_create_session(source)
assert reloaded.session_id == new_sid
assert store.load_transcript(reloaded.session_id) == []
@@ -0,0 +1,160 @@
import asyncio
import sys
import threading
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.session import SessionSource
SESSION_KEY = "agent:main:telegram:dm:12345"
class _SessionStore:
def __init__(self):
self.entry = SimpleNamespace(
session_key=SESSION_KEY,
session_id="session-before-compression",
)
self._entries = {SESSION_KEY: self.entry}
self.save_calls = 0
def _save(self):
self.save_calls += 1
class _CompressionThenFailureAgent:
def __init__(self, **kwargs):
self.session_id = kwargs["session_id"]
self.model = kwargs["model"]
self.tools = []
self.context_compressor = SimpleNamespace(
last_prompt_tokens=4321,
context_length=200000,
)
self.session_prompt_tokens = 4321
self.session_completion_tokens = 0
def run_conversation(self, user_message, conversation_history=None, task_id=None, **_kwargs):
self.session_id = "session-after-compression"
return {
"failed": True,
"error": "APIConnectionError: Codex auxiliary Responses stream exceeded 120.0s total timeout",
"messages": [
{"role": "user", "content": "[compressed summary]"},
{"role": "user", "content": user_message},
],
"api_calls": 1,
}
def interrupt(self, *_args, **_kwargs):
pass
class _StreamConsumer:
final_response_sent = False
def __init__(self, *_args, **_kwargs):
pass
async def run(self):
return None
def finish(self):
pass
class _Adapter:
SUPPORTS_MESSAGE_EDITING = True
_pending_messages = {}
def get_pending_message(self, _session_key):
return None
async def send_typing(self, *_args, **_kwargs):
return None
async def stop_typing(self, *_args, **_kwargs):
return None
def _runner(session_store):
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {Platform.TELEGRAM: _Adapter()}
runner.config = SimpleNamespace(streaming=None, group_sessions_per_user=True, thread_sessions_per_user=False)
runner.hooks = SimpleNamespace(loaded_hooks=False, emit=AsyncMock())
runner.session_store = session_store
runner._session_db = MagicMock()
runner._session_db.get_telegram_topic_binding_by_session.return_value = None
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._running_agents = {}
runner._running_agents_ts = {}
runner._session_run_generation = {}
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._pending_skills_reload_notes = {}
runner._prefill_messages = []
runner._ephemeral_system_prompt = ""
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._draining = False
runner._get_proxy_url = lambda: None
runner._resolve_session_agent_runtime = lambda **_kwargs: (
"gpt-5.4",
{"provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", "api_key": "token"},
)
runner._resolve_session_reasoning_config = lambda **_kwargs: None
runner._resolve_turn_agent_config = lambda message, model, runtime: {"model": model, "runtime": runtime}
runner._load_service_tier = lambda: None
runner._agent_config_signature = lambda *_args, **_kwargs: ("sig",)
runner._extract_cache_busting_config = lambda _config: ()
runner._thread_metadata_for_source = lambda *_args, **_kwargs: None
runner._sync_telegram_topic_binding = MagicMock()
runner._release_running_agent_state = MagicMock()
return runner
def test_failed_turn_still_syncs_compression_session_split(monkeypatch):
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CompressionThenFailureAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
monkeypatch.setenv("HERMES_AGENT_TIMEOUT", "0")
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr("gateway.stream_consumer.GatewayStreamConsumer", _StreamConsumer)
import hermes_cli.tools_config as tools_config
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda *_args, **_kwargs: {"core"})
session_store = _SessionStore()
runner = _runner(session_store)
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="user-1")
result = asyncio.run(
asyncio.wait_for(
runner._run_agent(
message="continue",
context_prompt="",
history=[{"role": "user", "content": "old question"}],
source=source,
session_id="session-before-compression",
session_key=SESSION_KEY,
),
timeout=2,
)
)
assert result["failed"] is True
assert result["session_id"] == "session-after-compression"
assert result["history_offset"] == 0
assert session_store.entry.session_id == "session-after-compression"
assert session_store.save_calls == 1
runner._sync_telegram_topic_binding.assert_called_once_with(
source, session_store.entry, reason="agent-run-compression"
)
+118 -19
View File
@@ -8,16 +8,21 @@ a message is dropped inside the adapter and never reaches the gateway unless it
already passed that policy.
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
the adapter. Before the fix it fell through to an env-only default-deny when no
``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy:
open`` and config-only allowlists even though the adapter had already
authorized the sender.
the adapter. Adapters that own their access policy declare
``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property, default
``False``) so the gateway can honor a config-only ``dm_policy: allowlist`` /
``allow_from`` (which the adapter already enforced) instead of double-denying it
when no ``PLATFORM_ALLOWED_USERS`` env var is set.
The fix is a single drift-proof contract: adapters that own their access policy
declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property,
default ``False``). The gateway trusts that flag and skips the env-only
default-deny for those platforms, rather than re-implementing each adapter's
policy logic a second time.
Crucially, the flag is NOT a blanket "already authorized" pass. These adapters
default ``dm_policy`` / ``group_policy`` to ``"open"``, which forwards *every*
sender, so the gateway trusts the adapter only when its effective policy for the
chat type is an actual ``"allowlist"`` restriction. Trusting ``"open"`` here
admitted the whole external network with no operator-configured allowlist the
fail-open SECURITY.md §2.6 forbids for network-exposed adapters ("an allowlist
is required for every enabled network-exposed adapter ... code paths that fail
open when no allowlist is configured are code bugs"). Open access requires an
explicit ``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
"""
from types import SimpleNamespace
@@ -128,15 +133,16 @@ def test_own_policy_adapters_declare_the_flag(module_path, class_name):
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform):
"""A message reaching the gateway from an own-policy adapter is trusted.
def test_own_policy_allowlist_authorized_without_env_allowlist(monkeypatch, platform):
"""A config-only ``dm_policy: allowlist`` is trusted without an env allowlist.
With no env allowlist set, the gateway must NOT default-deny the adapter
already authorized the sender at intake (e.g. ``dm_policy: open``).
The adapter only forwards an allowlisted sender under ``allowlist`` policy,
so a message reaching the gateway *was* authorized for this specific sender.
The gateway must honor that instead of double-denying (the #34515 case).
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "allowlist"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
@@ -144,15 +150,104 @@ def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platf
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform):
"""Group traffic from an own-policy adapter is trusted the same way."""
def test_own_policy_open_dm_not_authorized_without_allowlist(monkeypatch, platform):
"""``dm_policy: open`` forwards everyone → NOT authorization (SECURITY.md §2.6).
With no env allowlist and no per-platform allow-all flag, an own-policy
adapter running ``open`` (the default) must NOT fail open: the gateway falls
through to default-deny so the whole external network can't reach the agent.
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform)) is False
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_default_open_dm_is_fail_closed(monkeypatch, platform):
"""The adapters' *default* ``open`` policy (no config at all) fails closed.
Operators who enable an own-policy adapter with only credentials get
``dm_policy = "open"`` resolved on the live adapter. Simulate that resolved
state (empty config.extra, adapter ``_dm_policy = "open"``) and confirm the
gateway denies the do-nothing default must not be open to the world.
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, extra={})})
runner, adapter = _make_runner(platform, config, enforces=True)
adapter._dm_policy = "open" # as the live adapter resolves the default
assert runner._is_user_authorized(_source(platform)) is False
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_allowlist_authorized_for_group_chat(monkeypatch, platform):
"""A config-only ``group_policy: allowlist`` is trusted for group traffic."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "allowlist"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_open_group_not_authorized_without_allowlist(monkeypatch, platform):
"""``group_policy: open`` is the same fail-open class as DM open → deny."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
assert runner._is_user_authorized(_source(platform, chat_type="group")) is False
def test_wecom_open_group_with_per_group_sender_allowlist_is_authorized(monkeypatch):
"""WeCom ``groups.<id>.allow_from`` is an adapter-enforced restriction.
The top-level group policy is still ``open`` for the chat ID, but the
adapter has already checked the sender allowlist before dispatching to the
gateway. That is not the fail-open case and must not be double-denied.
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.WECOM: PlatformConfig(
enabled=True,
extra={
"group_policy": "open",
"groups": {"some-chat": {"allow_from": ["some-user"]}},
},
)
}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
def test_wecom_open_group_with_wildcard_sender_allowlist_is_authorized(monkeypatch):
"""Wildcard group config also gates senders before gateway auth runs."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.WECOM: PlatformConfig(
enabled=True,
extra={
"group_policy": "open",
"groups": {"*": {"allow_from": ["user_admin"]}},
},
)
}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
def test_non_owning_platform_still_default_denies(monkeypatch):
@@ -259,12 +354,16 @@ def test_pairing_carveout_reads_adapter_when_env_set(monkeypatch):
def test_pairing_dm_policy_group_chat_still_trusted(monkeypatch):
"""Pairing is DM-only — group traffic keeps the adapter-trust path."""
"""Pairing is DM-only — the DM pairing carve-out doesn't gate group traffic.
Group access is governed by ``group_policy``, so an allowlisted group is
still trusted even while DMs are in ``pairing`` mode.
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.WECOM: PlatformConfig(
enabled=True, extra={"dm_policy": "pairing", "group_policy": "open"}
enabled=True, extra={"dm_policy": "pairing", "group_policy": "allowlist"}
)
}
)
+12 -1
View File
@@ -151,7 +151,18 @@ class TestSupportedDocumentTypes:
@pytest.mark.parametrize(
"ext",
[".pdf", ".md", ".txt", ".zip", ".docx", ".xlsx", ".pptx"],
[
".pdf",
".md",
".txt",
".zip",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
],
)
def test_expected_extensions_present(self, ext):
assert ext in SUPPORTED_DOCUMENT_TYPES
+18
View File
@@ -1090,6 +1090,24 @@ class TestMatrixMarkdownToHtml:
assert "<code" in result
assert "print" in result
def test_matrix_markdown_preserves_table_structure(self):
table = "\n".join(
[
"| Item | Quantity |",
"| --- | --- |",
"| Apples | 4 |",
"| Bread | 1 |",
]
)
result = self.adapter._markdown_to_html(table)
assert "<table>" in result
assert "<thead>" in result
assert "<tbody>" in result
assert "<th>Item</th>" in result
assert "<td>Apples</td>" in result
# ---------------------------------------------------------------------------
# Helper: display name extraction
@@ -35,6 +35,7 @@ import pytest
from gateway.config import GatewayConfig, HomeChannel, Platform
from gateway.platforms.base import MessageEvent, MessageType, SendResult
from gateway.run import (
_AGENT_PENDING_SENTINEL,
_auto_continue_freshness_window,
_coerce_gateway_timestamp,
_is_fresh_gateway_interruption,
@@ -1420,3 +1421,194 @@ class TestStuckLoopEscalation:
{"indent": None},
)
]
@pytest.mark.asyncio
async def test_auto_resume_sets_sentinel_before_task_execution():
"""Auto-resume must claim the session slot before the task starts.
Regression for #45456: between ``asyncio.create_task()`` and the task's
first await (where ``_process_message_background`` sets the real
sentinel), an inbound message could arrive and spin up a duplicate
AIAgent. The fix pre-claims the slot so the inbound path sees it as
occupied.
"""
runner, adapter = make_restart_runner()
source = make_restart_source(chat_id="race-chat")
pending_entry = SessionEntry(
session_key="agent:main:telegram:dm:race-chat",
session_id="sid",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
platform=Platform.TELEGRAM,
chat_type="dm",
resume_pending=True,
resume_reason="restart_interrupted",
last_resume_marked_at=datetime.now(),
)
runner.session_store._entries = {pending_entry.session_key: pending_entry}
# Slow mock: hold the task open so we can inspect _running_agents
# while it's in-flight.
gate = asyncio.Event()
async def _slow_handle(event):
await gate.wait()
adapter.handle_message = _slow_handle
scheduled = runner._schedule_resume_pending_sessions()
assert scheduled == 1
# The sentinel must be set immediately — before the task starts executing.
assert pending_entry.session_key in runner._running_agents
assert runner._running_agents[pending_entry.session_key] is _AGENT_PENDING_SENTINEL
assert pending_entry.session_key in runner._running_agents_ts
# Release the task and let it complete.
gate.set()
await asyncio.sleep(0.05)
# After the task completes, the sentinel should be cleaned up.
assert pending_entry.session_key not in runner._running_agents
@pytest.mark.asyncio
async def test_auto_resume_sentinel_cleaned_on_task_failure():
"""If handle_message raises before _process_message_background, the
sentinel must still be released so the session is not locked forever.
"""
runner, adapter = make_restart_runner()
source = make_restart_source(chat_id="fail-chat")
pending_entry = SessionEntry(
session_key="agent:main:telegram:dm:fail-chat",
session_id="sid",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
platform=Platform.TELEGRAM,
chat_type="dm",
resume_pending=True,
resume_reason="restart_interrupted",
last_resume_marked_at=datetime.now(),
)
runner.session_store._entries = {pending_entry.session_key: pending_entry}
async def _failing_handle(event):
raise RuntimeError("adapter exploded")
adapter.handle_message = _failing_handle
scheduled = runner._schedule_resume_pending_sessions()
assert scheduled == 1
# Sentinel is set immediately.
assert pending_entry.session_key in runner._running_agents
# Let the task run and fail.
await asyncio.sleep(0.05)
# The sentinel must be cleaned up despite the failure.
assert pending_entry.session_key not in runner._running_agents
@pytest.mark.asyncio
async def test_auto_resume_runs_agent_exactly_once_through_full_path():
"""Full-path regression: the pre-claim must NOT make auto-resume a no-op.
The two tests above mock ``adapter.handle_message`` outright, so they
only prove the sentinel is set/cleaned around a stub they never
exercise the real dispatch chain. This drives the production path
end to end:
_schedule_resume_pending_sessions
-> _guarded_handle_message
-> adapter.handle_message (real)
-> _process_message_background (real)
-> _handle_message (real)
The risk the pre-claim introduces is a *self-bounce*: the resume
turn's own ``_handle_message`` sees the sentinel it pre-claimed at
the early running-agent guard, queues the event into
``_pending_messages`` and returns ``None`` without running the
agent. The adapter's late-arrival drain (in
``_process_message_background``'s ``finally``) re-dispatches the
queued event, and because the guard wrapper's ``finally`` releases
the pre-claim before the spawned drain task starts, the agent runs
exactly once. This test locks that invariant in: the resume agent
must run once never zero (regression) and never twice (the bug
the fix targets).
"""
runner, adapter = make_restart_runner()
source = make_restart_source(chat_id="full-path-chat")
session_key = runner._session_key_for_source(source)
pending_entry = SessionEntry(
session_key=session_key,
session_id="sid",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
platform=Platform.TELEGRAM,
chat_type="dm",
resume_pending=True,
resume_reason="restart_interrupted",
last_resume_marked_at=datetime.now(),
)
runner.session_store._entries = {session_key: pending_entry}
# Wire the REAL runner pipeline that _handle_message depends on.
from gateway.run import GatewayRunner
runner._handle_message = GatewayRunner._handle_message.__get__(
runner, GatewayRunner
)
runner._release_running_agent_state = (
GatewayRunner._release_running_agent_state.__get__(runner, GatewayRunner)
)
runner._check_slash_access = lambda *a, **k: None
runner._begin_session_run_generation = lambda session_key: 1
runner._is_session_run_current = lambda session_key, generation: True
runner._invalidate_session_run_generation = lambda *a, **k: 0
runner._claim_active_session_slot = lambda session_key, source: (object(), None)
runner._active_session_leases = {}
runner._busy_ack_ts = {}
runner._post_turn_goal_continuation = AsyncMock()
runner.session_store.get_or_create_session.return_value = None
# Count how many times an actual agent run is started for this session.
agent_runs: list[str] = []
async def _fake_run(event, source, _quick_key, run_generation):
agent_runs.append(_quick_key)
return "RESUMED OK"
runner._handle_message_with_agent = _fake_run
# Route the adapter's real background pipeline at the real handler,
# and stub the leaf send/typing calls so delivery is a no-op.
adapter.set_message_handler(runner._handle_message)
adapter.send = AsyncMock()
adapter._keep_typing = AsyncMock()
adapter._stop_typing_refresh = AsyncMock()
adapter._send_with_retry = AsyncMock(
return_value=SendResult(success=True, message_id="1")
)
adapter._run_processing_hook = AsyncMock()
scheduled = runner._schedule_resume_pending_sessions()
assert scheduled == 1
# Pre-claim must be visible immediately.
assert runner._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL
# Let the guarded task, the background task, and the late-arrival
# drain task all settle.
for _ in range(20):
await asyncio.sleep(0.02)
# Exactly one agent run for the resumed session — not zero (the
# pre-claim did not swallow the resume) and not two (no duplicate).
assert agent_runs == [session_key]
# No leaked sentinel and no orphaned queued event.
assert session_key not in runner._running_agents
assert session_key not in getattr(adapter, "_pending_messages", {})
+186
View File
@@ -321,3 +321,189 @@ class TestAlreadySentInDraftMode:
# After the regular sendMessage finalize, _already_sent is True.
assert consumer._already_sent is True
def _make_fresh_final_adapter():
"""Build a non-draft adapter that prefers a fresh final send.
Mirrors Telegram's rich-message contract: REQUIRES_EDIT_FINALIZE so the
final tick is routed through even when the text is unchanged, and
prefers_fresh_final_streaming() True so the consumer delivers the final
answer via a *fresh* send + preview delete instead of an edit.
``send`` returns two distinct ids so the test can tell the preview
(first send) from the fresh final (second send) and assert the preview
is the one deleted.
"""
from gateway.platforms.base import BasePlatformAdapter, SendResult
FreshFinalAdapter = type(
"FreshFinalAdapter",
(BasePlatformAdapter,),
{"MAX_MESSAGE_LENGTH": 4096, "REQUIRES_EDIT_FINALIZE": True},
)
FreshFinalAdapter.__abstractmethods__ = frozenset()
adapter = FreshFinalAdapter.__new__(FreshFinalAdapter)
adapter._typing_paused = set()
adapter._fatal_error_message = None
# Edit-based path only — no native drafts.
adapter.supports_draft_streaming = lambda chat_type=None, metadata=None: False
# Accepts the metadata kwarg the consumer passes; ignores it (like Telegram).
adapter.prefers_fresh_final_streaming = lambda content, metadata=None: True
adapter.send = AsyncMock(side_effect=[
SendResult(success=True, message_id="preview1"),
SendResult(success=True, message_id="final1"),
])
adapter.edit_message = AsyncMock(return_value=SendResult(success=True))
adapter.delete_message = AsyncMock(return_value=True)
return adapter
class TestAdapterPrefersFreshFinal:
"""An adapter whose send path is richer than its edit path (e.g. Telegram
rich messages) finalizes a streamed reply by sending a fresh final message
and deleting the preview, instead of final-editing the preview."""
@pytest.mark.asyncio
async def test_edit_stream_finalizes_with_fresh_send_and_deletes_preview(self):
adapter = _make_fresh_final_adapter()
cfg = StreamConsumerConfig(
transport="auto", chat_type="dm",
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.0, # only the adapter hook drives fresh-final
)
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
consumer.on_delta("Full answer here")
task = asyncio.create_task(consumer.run())
# Let the first send land so a real preview message_id exists before
# finalization — the fresh-final path only engages with a live preview.
await asyncio.sleep(0.05)
consumer.finish()
await task
# Two sends: the streaming preview, then the fresh final.
assert adapter.send.await_count == 2
first_content = adapter.send.call_args_list[0].kwargs.get("content")
second_content = adapter.send.call_args_list[1].kwargs.get("content")
# First update delivered the preview via adapter.send.
assert first_content == "Full answer here"
# Finalization re-sent the same completed content as a fresh message.
assert second_content == "Full answer here"
# The edit path must NOT be used to finalize a rich preview.
adapter.edit_message.assert_not_called()
# The stale preview is best-effort deleted (by its id, not the final's).
adapter.delete_message.assert_awaited_once_with("12345", "preview1")
assert consumer.final_response_sent is True
def _make_rich_capable_adapter(*, overflow_limit=32768, send_results=None):
"""Non-draft adapter that mimics Telegram rich messages: REQUIRES_EDIT_FINALIZE,
prefers a fresh (rich) final send, and reports a 32,768 streaming overflow
limit so the consumer doesn't pre-split a reply that fits one rich message.
"""
from gateway.platforms.base import BasePlatformAdapter, SendResult
RichAdapter = type(
"RichCapableAdapter",
(BasePlatformAdapter,),
{"MAX_MESSAGE_LENGTH": 4096, "REQUIRES_EDIT_FINALIZE": True},
)
RichAdapter.__abstractmethods__ = frozenset()
adapter = RichAdapter.__new__(RichAdapter)
adapter._typing_paused = set()
adapter._fatal_error_message = None
adapter.supports_draft_streaming = lambda chat_type=None, metadata=None: False
adapter.prefers_fresh_final_streaming = lambda content, metadata=None: True
adapter.streaming_overflow_limit = lambda: overflow_limit
adapter.send = AsyncMock(side_effect=send_results) if send_results else AsyncMock(
return_value=SendResult(success=True, message_id="m1"),
)
adapter.edit_message = AsyncMock(return_value=SendResult(success=True))
adapter.delete_message = AsyncMock(return_value=True)
return adapter
class TestRichAwareOverflow:
"""Rich-capable adapters raise the consumer's overflow limit so a reply that
fits one rich message isn't fragmented at the legacy 4,096 edit limit."""
def test_raw_message_limit_uses_adapter_rich_cap(self):
adapter = _make_rich_capable_adapter(overflow_limit=32768)
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
assert consumer._raw_message_limit() == 32768
def test_raw_message_limit_falls_back_to_max_length(self):
# Adapter whose hook returns None (default) keeps the legacy limit.
adapter = _make_rich_capable_adapter()
adapter.streaming_overflow_limit = lambda: None
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
assert consumer._raw_message_limit() == 4096
def test_raw_message_limit_mock_adapter_is_safe(self):
# MagicMock adapters (many existing tests) must not crash or wrongly
# inflate the limit from a truthy auto-attribute.
adapter = MagicMock()
adapter.MAX_MESSAGE_LENGTH = 4096
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
assert consumer._raw_message_limit() == 4096
@pytest.mark.asyncio
async def test_long_rich_reply_not_split_and_final_is_whole(self):
from gateway.platforms.base import SendResult
long_text = "x" * 5000 # > 4096 legacy limit, < 32768 rich limit
adapter = _make_rich_capable_adapter(send_results=[
SendResult(success=True, message_id="preview1"),
SendResult(success=True, message_id="final1"),
])
cfg = StreamConsumerConfig(
transport="auto", chat_type="dm",
edit_interval=0.01, buffer_threshold=5, cursor="",
fresh_final_after_seconds=0.0,
)
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
consumer.on_delta(long_text)
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
consumer.finish()
await task
# Exactly two whole sends: the preview and the fresh final — NOT split
# into ~4096 chunks. Both carry the full 5,000-char reply.
assert adapter.send.await_count == 2
assert adapter.send.call_args_list[0].kwargs.get("content") == long_text
assert adapter.send.call_args_list[1].kwargs.get("content") == long_text
adapter.edit_message.assert_not_called()
adapter.delete_message.assert_awaited_once_with("12345", "preview1")
assert consumer.final_response_sent is True
@pytest.mark.asyncio
async def test_fresh_final_deletes_all_preview_fragments(self):
from gateway.platforms.base import SendResult
adapter = _make_rich_capable_adapter(send_results=[
SendResult(success=True, message_id="final1"),
])
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
# Simulate a reply that was split across the edit limit while streaming:
# three preview fragments, the last of which is the current message.
consumer._message_id = "frag3"
consumer._preview_message_ids = {"frag1", "frag2", "frag3"}
ok = await consumer._try_fresh_final("the whole completed answer")
assert ok is True
# All three stale fragments deleted; the fresh final never deleted.
deleted = {c.args[1] for c in adapter.delete_message.await_args_list}
assert deleted == {"frag1", "frag2", "frag3"}
assert "final1" not in deleted
assert consumer._message_id == "final1"
assert consumer._preview_message_ids == set()
assert consumer.final_response_sent is True
@@ -103,7 +103,8 @@ class TestInitialReplyToId:
await consumer._send_or_edit("Test")
call_kwargs = adapter.send.call_args[1]
assert call_kwargs["metadata"] == metadata
assert call_kwargs["metadata"] == {**metadata, "expect_edits": True}
assert metadata == {"thread_id": "omt_topic789"}
class TestOverflowFirstMessage:
+145 -38
View File
@@ -25,19 +25,24 @@ from telegram.error import BadRequest, NetworkError, TimedOut
# and a task list. Pipes / brackets must survive untouched into the payload.
RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n- [x] table renders"
# PTB 22.6's real unknown-endpoint errors: do_api_request can raise
# EndPointNotFound for Bot API 404s, and the request layer can wrap that same
# missing endpoint as InvalidToken. Use class names here so the tests don't
# depend on optional PTB internals.
EndPointNotFound = type("EndPointNotFound", (Exception,), {})
InvalidToken = type("InvalidToken", (Exception,), {})
PTB_ENDPOINT_NOT_FOUND = EndPointNotFound(
"Endpoint 'sendRichMessage' not found in Bot API"
)
PTB_INVALID_TOKEN_404 = InvalidToken(
"Either the bot token was rejected by Telegram or the endpoint "
"'sendRichMessage' does not exist."
)
def _make_adapter(extra=None):
"""Build a TelegramAdapter with a mock bot wired for the rich path.
Rich messages are opt-in (default off) while the Bot API 10.1 endpoint
is validated live, so tests that exercise the rich path enable it
explicitly here; opt-out tests pass their own ``extra``.
"""
config = PlatformConfig(
enabled=True,
token="fake-token",
extra={"rich_messages": True} if extra is None else extra,
)
"""Build a TelegramAdapter with a mock bot wired for the rich path."""
config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {})
adapter = TelegramAdapter(config)
bot = MagicMock()
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
@@ -57,6 +62,31 @@ def _rich_api_kwargs(adapter):
return call.kwargs["api_kwargs"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("raw", "expected_id"),
[
(SimpleNamespace(message_id=123), "123"),
({"message_id": 123}, "123"),
({"result": {"message_id": 123}}, "123"),
({"result": None}, None),
],
)
async def test_rich_result_shapes_extract_message_id(raw, expected_id):
"""The raw Bot API path may return either a PTB object or a raw dict."""
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(return_value=raw)
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
assert result.message_id == expected_id
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_rich_happy_path_sends_raw_markdown():
adapter = _make_adapter()
@@ -76,32 +106,43 @@ async def test_rich_happy_path_sends_raw_markdown():
@pytest.mark.asyncio
async def test_rich_opt_out_uses_legacy():
async def test_legacy_rich_messages_config_is_ignored():
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message.assert_awaited()
# The legacy toggle was removed; stale config entries must not disable the
# rich path.
adapter._bot.do_api_request.assert_awaited_once()
adapter._bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_rich_opt_out_accepts_string_false():
adapter = _make_adapter(extra={"rich_messages": "false"})
async def test_expect_edits_metadata_keeps_preview_on_legacy_path():
adapter = _make_adapter()
await adapter.send("12345", RICH_CONTENT)
result = await adapter.send(
"12345",
RICH_CONTENT,
metadata={"expect_edits": True},
)
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message.assert_awaited()
assert result.success is True
# Streaming preview sends will be edited later, so they must not be born as
# rich messages until Hermes wires rich_message edits directly.
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_not_called()
bot.send_message.assert_awaited()
@pytest.mark.asyncio
async def test_oversized_content_skips_rich_and_chunks():
adapter = _make_adapter()
# > 32,768 UTF-8 bytes -> rich pre-check fails, legacy chunking takes over.
# > 32,768 characters -> rich pre-check fails, legacy chunking takes over.
oversized = "a" * 40000
assert len(oversized.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
assert len(oversized) > TelegramAdapter.RICH_MESSAGE_MAX_CHARS
result = await adapter.send("12345", oversized)
@@ -111,6 +152,23 @@ async def test_oversized_content_skips_rich_and_chunks():
assert adapter._bot.send_message.await_count > 1
@pytest.mark.asyncio
async def test_rich_limit_is_characters_not_bytes():
"""Telegram's rich limit is UTF-8 characters, not encoded bytes."""
adapter = _make_adapter()
cjk = "" * 20000 # 20k chars, 60k UTF-8 bytes
assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
result = await adapter.send("12345", cjk)
assert result.success is True
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exc",
@@ -162,6 +220,33 @@ async def test_capability_error_latches_rich_send_off():
adapter._bot.send_message.assert_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("exc", [PTB_ENDPOINT_NOT_FOUND, PTB_INVALID_TOKEN_404])
async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(side_effect=exc)
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_awaited()
assert adapter._rich_send_disabled is True
@pytest.mark.asyncio
async def test_rich_payload_preserves_link_preview_disable():
adapter = _make_adapter(extra={"disable_link_previews": True})
result = await adapter.send("12345", "See https://example.com")
assert result.success is True
api_kwargs = _rich_api_kwargs(adapter)
assert api_kwargs["link_preview_options"] == {"is_disabled": True}
@pytest.mark.asyncio
async def test_per_message_bad_request_does_not_latch_off():
"""A parser/limit BadRequest is per-message — rich must stay enabled
@@ -265,13 +350,9 @@ async def test_notification_opt_in_drops_disable_flag():
@pytest.mark.asyncio
async def test_rich_gate_tolerates_missing_enabled_attr():
"""Adapters missing _rich_messages_enabled (object.__new__ in some tests)
must not raise the gate reads it via getattr(default=True), and a bot
without an async do_api_request falls through to the legacy path."""
async def test_rich_gate_tolerates_minimal_bot_without_raw_endpoint():
"""A bot without an async do_api_request falls through to the legacy path."""
adapter = _make_adapter()
del adapter._rich_messages_enabled # simulate object.__new__ construction
# SimpleNamespace bot has no do_api_request -> _bot_supports_rich() False.
adapter._bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=42)),
send_chat_action=AsyncMock(),
@@ -337,17 +418,6 @@ async def test_rich_draft_transient_failure_does_not_latch_off():
assert adapter._rich_draft_disabled is False
@pytest.mark.asyncio
async def test_rich_draft_opt_out_uses_legacy():
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message_draft.assert_awaited_once()
@pytest.mark.asyncio
async def test_rich_draft_oversized_uses_legacy():
adapter = _make_adapter()
@@ -358,3 +428,40 @@ async def test_rich_draft_oversized_uses_legacy():
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message_draft.assert_awaited_once()
# ----------------------------------------------------------------------
# prefers_fresh_final_streaming: the stream consumer asks the adapter whether
# to finalize a streamed reply by sending a fresh (rich) message + deleting the
# preview, instead of final-editing the preview through the non-rich edit path.
# Telegram opts in exactly when the content is rich-eligible.
# ----------------------------------------------------------------------
def test_prefers_fresh_final_streaming_when_rich_enabled():
adapter = _make_adapter()
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True
def test_prefers_fresh_final_streaming_ignores_legacy_toggle():
adapter = _make_adapter(extra={"rich_messages": False})
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True
# ----------------------------------------------------------------------
# streaming_overflow_limit: with rich on, the stream consumer may accumulate up
# to the 32,768-char rich cap before splitting, so a reply that fits one
# sendRichMessage / sendRichMessageDraft isn't fragmented at the 4,096 limit.
# ----------------------------------------------------------------------
def test_streaming_overflow_limit_is_rich_cap_when_enabled():
adapter = _make_adapter()
assert adapter.streaming_overflow_limit() == TelegramAdapter.RICH_MESSAGE_MAX_CHARS
def test_streaming_overflow_limit_ignores_legacy_toggle():
adapter = _make_adapter(extra={"rich_messages": False})
assert adapter.streaming_overflow_limit() == TelegramAdapter.RICH_MESSAGE_MAX_CHARS
def test_streaming_overflow_limit_none_when_rich_latched_off():
adapter = _make_adapter()
adapter._rich_send_disabled = True
assert adapter.streaming_overflow_limit() is None
@@ -76,6 +76,7 @@ def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkey
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home, access_token="")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))
with pytest.raises(AuthError) as exc:
resolve_codex_runtime_credentials()
@@ -0,0 +1,208 @@
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
surfacing a hard 401 but ONLY for relogin-required failures, never for transient
ones (e.g. 429 quota, where the stored token is still valid).
"""
import json
import pytest
import hermes_cli.auth as auth
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
def test_self_heals_on_stale_refresh_token(monkeypatch):
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
saved = {}
fresh = {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"last_refresh": "2026-06-12T00:00:00Z",
}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens(STALE, 20.0)
assert out["access_token"] == "fresh-access"
assert out["refresh_token"] == "fresh-refresh"
# the recovered token was persisted to the Hermes auth store
assert saved["access_token"] == "fresh-access"
def test_does_not_self_heal_on_rate_limit(monkeypatch):
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
import_calls = {"n": 0}
def _rate_limited(*_a, **_k):
raise AuthError(
"quota exhausted",
provider="openai-codex",
code="codex_rate_limited",
relogin_required=False,
)
def _import_spy():
import_calls["n"] += 1
return {"access_token": "should-not-be-used"}
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "codex_rate_limited"
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure
def test_reraises_when_codex_cli_token_absent(monkeypatch):
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""
def _reused(*_a, **_k):
raise AuthError(
"refresh token reused",
provider="openai-codex",
code="refresh_token_reused",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "refresh_token_reused"
def test_happy_path_unchanged(monkeypatch):
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
saved = {}
import_calls = {"n": 0}
def _import_spy():
import_calls["n"] += 1
return None
monkeypatch.setattr(
auth,
"refresh_codex_oauth_pure",
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)
assert out["access_token"] == "rotated"
assert out["refresh_token"] == "rotated-r"
assert saved["access_token"] == "rotated"
assert import_calls["n"] == 0 # happy path must not consult ~/.codex
def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
re-raise rather than persist a half-token that would break the next refresh."""
saved = {}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "invalid_grant"
assert saved == {} # nothing was persisted
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"last_refresh": "2026-06-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
resolved = resolve_codex_runtime_credentials()
assert resolved["api_key"] == "fresh-access"
assert resolved["source"] == "hermes-auth-store"
stored = json.loads((hermes_home / "auth.json").read_text())
tokens = stored["providers"]["openai-codex"]["tokens"]
assert tokens["access_token"] == "fresh-access"
assert tokens["refresh_token"] == "fresh-refresh"
def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
"""Missing access_token must not be masked by a malformed Codex CLI import."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "fresh-only"},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
with pytest.raises(AuthError) as ei:
resolve_codex_runtime_credentials()
assert ei.value.code == "codex_auth_missing_access_token"
@@ -0,0 +1,99 @@
"""Unit tests for find_custom_provider_identity (base_url → custom:<name>).
Reverse lookup used by tui_gateway session persistence to recover a named
``providers:`` / ``custom_providers:`` entry from the only durable fact the
session row keeps once the provider has been resolved to the literal string
"custom": the endpoint URL. See
tests/tui_gateway/test_custom_provider_session_persistence.py for the
end-to-end persist/resume round-trip.
"""
import hermes_cli.runtime_provider as rp
def test_matches_legacy_custom_providers_list(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "MiMo v2.5 Pro", "base_url": "https://api.mimo.example/v1"}
]
},
)
assert (
rp.find_custom_provider_identity("https://api.mimo.example/v1")
== "custom:mimo-v2.5-pro"
)
def test_matches_providers_dict_by_key(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
)
assert (
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
== "custom:local"
)
def test_match_ignores_trailing_slash_and_case(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
]
},
)
assert (
rp.find_custom_provider_identity("http://localhost:8000/v1")
== "custom:local"
)
def test_no_match_returns_none(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "other", "base_url": "https://elsewhere.example/v1"}
]
},
)
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
def test_empty_base_url_returns_none(monkeypatch):
monkeypatch.setattr(
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
)
assert rp.find_custom_provider_identity("") is None
assert rp.find_custom_provider_identity(None) is None
def test_identity_resolves_back_through_named_lookup(monkeypatch):
"""The returned slug must be accepted by _get_named_custom_provider —
that is the whole point of persisting it."""
config = {
"custom_providers": [
{
"name": "mimo-v2.5-pro",
"base_url": "https://api.mimo.example/v1",
"api_key": "sk-entry",
}
]
}
monkeypatch.setattr(rp, "load_config", lambda: config)
slug = rp.find_custom_provider_identity("https://api.mimo.example/v1")
assert slug == "custom:mimo-v2.5-pro"
entry = rp._get_named_custom_provider(slug)
assert entry is not None
assert entry["base_url"] == "https://api.mimo.example/v1"
assert entry["api_key"] == "sk-entry"
@@ -96,3 +96,40 @@ def test_status_preserves_existing_fields(loopback_client):
}
missing = expected_keys - set(body.keys())
assert not missing, f"/api/status dropped fields: {missing}"
# Host-local detail (absolute paths, PID, internal gateway URL) is deployment
# recon a liveness probe never needs. ``/api/status`` bypasses dashboard auth
# (it is in ``PUBLIC_API_PATHS``), so on a network-exposed bind it must not
# leak that detail to anonymous callers.
_HOST_DETAIL_FIELDS = frozenset({
"hermes_home", "config_path", "env_path", "gateway_pid",
"gateway_health_url",
})
def test_status_withholds_host_detail_in_gated_mode(gated_client):
"""On a gated (non-loopback) bind, the public ``/api/status`` probe must
expose only the liveness + auth-gate shape never absolute host paths,
the gateway PID, or the internal gateway health URL. The endpoint
bypasses dashboard auth, so anyone who can reach the host hits it cold."""
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
# Liveness / auth-gate shape stays public.
for key in ("version", "gateway_state", "auth_required", "auth_providers"):
assert key in body, f"liveness field {key!r} must stay public"
# Deployment recon must be withheld from the anonymous public probe.
leaked = _HOST_DETAIL_FIELDS & set(body.keys())
assert not leaked, f"/api/status leaked host detail under the gate: {leaked}"
def test_status_includes_host_detail_in_loopback_mode(loopback_client):
"""Counterpart to the gated case: a loopback bind is local-only, so the
full payload (including host paths and PID) is still served preserving
the StatusPage / ``hermes status`` experience for local operators."""
r = loopback_client.get("/api/status")
assert r.status_code == 200
body = r.json()
missing = _HOST_DETAIL_FIELDS - set(body.keys())
assert not missing, f"loopback /api/status should keep host detail: {missing}"
+69
View File
@@ -1406,3 +1406,72 @@ class TestDoctorStaleMaxIterationsDrift:
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
)
assert "shadows" not in out
def test_npm_audit_fix_hint_avoids_crashing_workspace_flag(monkeypatch, tmp_path):
"""`hermes doctor` must not hand users `npm audit fix --workspace <name>`:
that exact form crashes npm with "Cannot read properties of null (reading
'edgesOut')" (an arborist bug with workspace-filtered audit fix).
It must not recommend root-level `npm audit fix` for workspace advisories
either: current npm can crash there too with "Cannot read properties of null
(reading 'isDescendantOf')" on this tree. The safe guidance is that these
build-tool advisories clear via the lockfile/package bump.
Regression for user reports where doctor flagged the web/ui-tui workspaces
and the suggested fix command errored out.
"""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
project = tmp_path / "project"
(project / "node_modules").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
# Only npm is "installed" — keeps the rest of run_doctor's external checks
# quiet without affecting the npm-audit branch under test.
monkeypatch.setattr(
doctor_mod.shutil, "which", lambda cmd: "/usr/bin/npm" if cmd == "npm" else None
)
def mock_run(cmd, **kwargs):
if "audit" in cmd and "--workspace" in cmd:
payload = (
'{"metadata": {"vulnerabilities": '
'{"critical": 0, "high": 2, "moderate": 0}}}'
)
return SimpleNamespace(returncode=1, stdout=payload, stderr="")
if "audit" in cmd:
payload = (
'{"metadata": {"vulnerabilities": '
'{"critical": 0, "high": 0, "moderate": 0}}}'
)
return SimpleNamespace(returncode=0, stdout=payload, stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
import subprocess
monkeypatch.setattr(subprocess, "run", mock_run)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
# The workspace vulnerability is still reported ...
assert "web workspace" in out
# ... but the remediation must NOT use the npm-crashing per-workspace form
# (`npm audit fix --workspace web` / `--workspace ui-tui`).
assert "npm audit fix --workspace web" not in out
assert "npm audit fix --workspace ui-tui" not in out
# ... and it must not point at the root-level form either: npm can crash
# there too with `isDescendantOf` on this monorepo tree.
assert "npm audit fix" not in out
# ... and explains the workspace advisories are build-time tooling whose
# manual remediation may hit a known npm arborist crash, so the user isn't
# left thinking a crashing command means a broken Hermes install.
assert "build-time tooling" in out
assert "known npm bug" in out
assert "lockfile bump" in out
+14
View File
@@ -274,6 +274,20 @@ def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkey
assert calls == [False]
def test_gateway_start_ignores_legacy_platform_selector(monkeypatch):
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
calls = []
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(system))
args = SimpleNamespace(gateway_command="start", system=False, all=False, platform="photon")
gateway.gateway_command(args)
assert calls == [False]
def test_gateway_restart_on_windows_without_service_uses_detached_backend(monkeypatch):
"""Windows manual restart must not fall back to foreground run_gateway().
+2 -1
View File
@@ -143,7 +143,8 @@ class TestGeminiModelNormalization:
assert normalize_model_for_provider("gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
def test_strip_vendor_prefix(self):
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "google/gemini-2.5-flash"
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
assert normalize_model_for_provider("gemini/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
def test_gemma_vendor_detection(self):
assert detect_vendor("gemma-4-31b-it") == "google"
@@ -0,0 +1,118 @@
from __future__ import annotations
import subprocess
def _make_task(kb, *, assignee: str):
return kb.Task(
id="t_spawn_tools",
title="spawn tools",
body=None,
assignee=assignee,
status="running",
priority=0,
created_by="test",
created_at=1,
started_at=None,
completed_at=None,
workspace_kind="dir",
workspace_path=None,
claim_lock="lock",
claim_expires=None,
tenant=None,
current_run_id=7,
)
def test_default_spawn_pins_assignee_profile_cli_toolsets(monkeypatch, tmp_path):
"""Manual profile assignment should keep that profile's CLI tools.
Regression guard for dispatcher-spawned workers that boot with
HERMES_KANBAN_TASK: the worker must not collapse to only kanban lifecycle
tools when the assigned profile's top-level ``toolsets`` is the default
composite. The spawned CLI gets an explicit --toolsets pin resolved from
platform_toolsets.cli; model_tools appends task-scoped kanban tools later.
"""
root = tmp_path / ".hermes"
profile = root / "profiles" / "elias"
profile.mkdir(parents=True)
profile.joinpath("config.yaml").write_text(
"""
platform_toolsets:
cli:
- clarify
- code_execution
- delegation
- file
- memory
- session_search
- skills
- terminal
- web
toolsets:
- hermes-cli
agent:
disabled_toolsets: []
""".lstrip(),
encoding="utf-8",
)
root.joinpath("config.yaml").write_text("toolsets:\n - kanban\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
from hermes_cli import kanban_db as kb
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
captured = {}
class FakeProc:
pid = 4242
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = list(cmd)
captured["env"] = dict(kwargs.get("env") or {})
captured["cwd"] = kwargs.get("cwd")
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
workspace = tmp_path / "workspace"
workspace.mkdir()
pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
assert pid == 4242
assert captured["env"]["HERMES_HOME"] == str(profile)
assert captured["env"]["HERMES_KANBAN_TASK"] == "t_spawn_tools"
assert "--toolsets" in captured["cmd"]
pinned = captured["cmd"][captured["cmd"].index("--toolsets") + 1].split(",")
for required in ("terminal", "web", "file", "skills", "code_execution", "delegation"):
assert required in pinned
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
root = tmp_path / ".hermes"
profile = root / "profiles" / "elias"
profile.mkdir(parents=True)
root.joinpath("config.yaml").write_text("platform_toolsets:\n cli:\n - kanban\n", encoding="utf-8")
profile.joinpath("config.yaml").write_text(
"""
platform_toolsets:
cli:
- terminal
- web
toolsets:
- hermes-cli
""".lstrip(),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(root))
from hermes_cli import kanban_db as kb
resolved = kb._resolve_worker_cli_toolsets(str(profile))
assert resolved is not None
assert "terminal" in resolved
assert "web" in resolved
assert "kanban" in resolved # recovered worker lifecycle surface
assert resolved != ["kanban"]
+4 -1
View File
@@ -167,10 +167,13 @@ class TestAggregatorProviders:
class TestIssue6211NativeProviderPrefixNormalization:
@pytest.mark.parametrize("model,target_provider,expected", [
("zai/glm-5.1", "zai", "glm-5.1"),
("google/gemini-2.5-pro", "gemini", "google/gemini-2.5-pro"),
("google/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
("gemini/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"),
("anthropic/claude-sonnet-4.6", "openrouter", "anthropic/claude-sonnet-4.6"),
("x-ai/grok-4-fast-reasoning", "xai", "grok-4-fast-reasoning"),
("Qwen/Qwen3.5-397B-A17B", "huggingface", "Qwen/Qwen3.5-397B-A17B"),
("openai/gpt-5.4", "xai", "openai/gpt-5.4"),
("modal/zai-org/GLM-5-FP8", "custom", "modal/zai-org/GLM-5-FP8"),
])
def test_native_provider_prefixes_are_only_stripped_on_matching_provider(
@@ -65,6 +65,61 @@ def test_resolve_provider_full_finds_named_custom_provider():
assert resolved.source == "user-config"
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
"""Bare model.provider=custom + model.base_url should still populate /model.
Users can configure a one-off OpenAI-compatible endpoint directly under
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
The gateway picker receives only the current model/base_url slice, so it
must surface that active endpoint rather than looking like config was
ignored.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
max_models=50,
)
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
assert bare_custom is not None
assert bare_custom["name"] == "Custom endpoint"
assert bare_custom["is_current"] is True
assert bare_custom["is_user_defined"] is True
assert bare_custom["models"] == ["gpt-4o"]
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
"""Picker selections for bare custom endpoints should route to current base_url."""
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
result = switch_model(
raw_input="gpt-4o-mini",
current_provider="custom",
current_model="gpt-4o",
current_base_url="https://www.ccsub.net/v1",
current_api_key="sk-test",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)
assert result.success is True
assert result.target_provider == "custom"
assert result.provider_label == "Custom endpoint"
assert result.new_model == "gpt-4o-mini"
assert result.base_url == "https://www.ccsub.net/v1"
assert result.api_key == "sk-test"
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
+48
View File
@@ -173,6 +173,54 @@ class TestResolveGitUrl:
assert url == "git@github.com:owner/repo.git"
assert subdir == "sub"
@pytest.mark.parametrize(
"identifier",
[
"https://github.com/owner/repo/tree/main",
"https://github.com/owner/repo/blob/main/README.md",
"https://github.com/owner/repo/pull/123",
"https://github.com/owner/repo/commit/abc123def",
"https://github.com/owner/repo/releases/tag/v1.0",
"https://github.com/owner/repo/issues/42",
],
)
def test_github_browser_url_normalized_to_repo(self, identifier):
url, subdir = _resolve_git_url(identifier)
assert url == "https://github.com/owner/repo.git"
assert subdir is None
@pytest.mark.parametrize(
("identifier", "expected_subdir"),
[
("https://github.com/owner/repo/tree/main/plugins/foo", "plugins/foo"),
("https://github.com/owner/repo/tree/feature-branch/plugin", "plugin"),
("https://github.com/owner/repo/tree/main/plugins/foo?plain=1", "plugins/foo"),
("https://github.com/owner/repo.git/tree/main/plugins/foo", "plugins/foo"),
],
)
def test_github_tree_browser_url_preserves_subdir(self, identifier, expected_subdir):
url, subdir = _resolve_git_url(identifier)
assert url == "https://github.com/owner/repo.git"
assert subdir == expected_subdir
@pytest.mark.parametrize(
"identifier",
[
"https://github.com/owner/repo",
"https://github.com/owner/repo.git",
"https://github.com/owner",
"https://github.com/owner/repo/branches",
"https://github.com/owner//tree/main",
"https://gitlab.com/owner/repo/tree/main",
"git@github.com:owner/repo.git",
"file:///tmp/repo/tree/main",
],
)
def test_non_browser_urls_passthrough(self, identifier):
url, subdir = _resolve_git_url(identifier)
assert url == identifier
assert subdir is None
# ── _resolve_subdir_within ──────────────────────────────────────────────────
@@ -81,3 +81,12 @@ def test_gateway_accept_hooks_flag():
p = _gateway_parser()
ns = p.parse_args(["gateway", "run", "--accept-hooks"])
assert ns.accept_hooks is True
def test_gateway_lifecycle_accepts_legacy_platform_flag():
p = _gateway_parser()
for action in ("start", "restart", "status"):
ns = p.parse_args(["gateway", action, "--platform", "photon"])
assert ns.gateway_command == action
assert ns.platform == "photon"
assert ns.func is _h_gateway
@@ -7,6 +7,7 @@ Windows-specific code paths can be exercised on any host.
from __future__ import annotations
import json
import os
import sys
import types
@@ -446,6 +447,97 @@ def test_quarantine_actionable_warning_when_everything_fails(
assert "Hermes Desktop" in captured or "gateway" in captured.lower()
# ---------------------------------------------------------------------------
# Windows gateway pause/resume before update mutation
# ---------------------------------------------------------------------------
@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
_winp,
monkeypatch,
tmp_path,
capsys,
):
import gateway.status as status_mod
import hermes_cli.gateway as gateway_mod
profile_home = tmp_path / "profiles" / "work"
profile_home.mkdir(parents=True)
profile_proc = SimpleNamespace(profile="work", path=profile_home, pid=101)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [101, 202])
monkeypatch.setattr(
gateway_mod,
"find_profile_gateway_processes",
lambda **_k: [profile_proc],
)
monkeypatch.setattr(gateway_mod, "_get_restart_drain_timeout", lambda: 0.1)
waited_for = []
def fake_wait(pids, *, timeout):
waited_for.extend(pids)
return set()
monkeypatch.setattr(cli_main, "_wait_for_windows_update_gateway_exit", fake_wait)
terminated = []
monkeypatch.setattr(
status_mod,
"terminate_pid",
lambda pid, force=False: terminated.append((pid, force)),
)
token = cli_main._pause_windows_gateways_for_update()
assert token == {
"resume_needed": True,
"profiles": {"work": 101},
"unmapped_pids": [202],
}
assert waited_for == [101]
assert terminated == [(202, True)]
marker = json.loads((profile_home / ".gateway-planned-stop.json").read_text())
assert marker["target_pid"] == 101
assert marker["stopper_pid"] == os.getpid()
captured = capsys.readouterr().out
assert "Paused gateway profile(s): work" in captured
assert "without profile mapping" in captured
@patch.object(cli_main, "_is_windows", return_value=True)
def test_resume_windows_gateways_after_update_relaunches_paused_profiles(
_winp,
monkeypatch,
capsys,
):
import hermes_cli.gateway as gateway_mod
relaunched = []
monkeypatch.setattr(
gateway_mod,
"launch_detached_profile_gateway_restart",
lambda profile, old_pid: relaunched.append((profile, old_pid)) or True,
)
token = {
"resume_needed": True,
"profiles": {"default": 101, "work": 202},
"unmapped_pids": [],
}
cli_main._resume_windows_gateways_after_update(token)
assert token["resume_needed"] is False
assert relaunched == [("default", 101), ("work", 202)]
assert (
"Restarting Windows gateway profile(s): default, work"
in capsys.readouterr().out
)
# ---------------------------------------------------------------------------
# cmd_update integration — concurrent-instance gate
# ---------------------------------------------------------------------------
+157 -4
View File
@@ -520,6 +520,87 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/profiles/sessions?archived=bogus")
assert resp.status_code == 400
def test_sessions_endpoint_reads_requested_profile(self):
"""The machine dashboard's global profile switcher must retarget
the Sessions page, not just config/skills/model pages."""
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-only", source="cli")
default_db.append_message("default-only", role="user", content="default")
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-only", source="cli")
worker_db.append_message("worker-only", role="user", content="worker")
finally:
worker_db.close()
resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0")
assert resp.status_code == 200
data = resp.json()
ids = {s["id"] for s in data["sessions"]}
assert "worker-only" in ids
assert "default-only" not in ids
row = next(s for s in data["sessions"] if s["id"] == "worker-only")
assert row["profile"] == "worker"
assert row["is_default_profile"] is False
stats = self.client.get("/api/sessions/stats?profile=worker").json()
assert stats["total"] == 1
assert stats["messages"] == 1
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
assert [m["content"] for m in messages["messages"]] == ["worker"]
def test_analytics_endpoints_read_requested_profile(self):
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-usage", source="cli", model="default/model")
default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5)
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model")
worker_db.update_token_counts(
"worker-usage",
input_tokens=123,
output_tokens=45,
billing_provider="worker-provider",
)
finally:
worker_db.close()
usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json()
assert usage["totals"]["total_sessions"] == 1
assert usage["totals"]["total_input"] == 123
assert [m["model"] for m in usage["by_model"]] == ["worker/model"]
models = self.client.get("/api/analytics/models?days=7&profile=worker").json()
assert models["totals"]["distinct_models"] == 1
assert models["totals"]["total_input"] == 123
assert models["models"][0]["model"] == "worker/model"
assert models["models"][0]["provider"] == "worker-provider"
default_usage = self.client.get("/api/analytics/usage?days=7").json()
assert default_usage["totals"]["total_input"] == 10
assert default_usage["totals"]["total_output"] == 5
def test_get_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
@@ -2552,7 +2633,7 @@ class TestNewEndpoints:
resp = self.client.post(
"/api/profiles",
json={"name": "writer", "clone_from_default": False},
json={"name": "writer", "clone_from": None},
)
assert resp.status_code == 200
@@ -2560,7 +2641,7 @@ class TestNewEndpoints:
assert wrapper_path.exists()
assert wrapper_path.read_text() == '#!/bin/sh\nexec hermes -p writer "$@"\n'
def test_profiles_create_with_clone_from_default_copies_default_skills(self, monkeypatch):
def test_profiles_create_with_clone_from_copies_source_skills(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod
@@ -2571,7 +2652,7 @@ class TestNewEndpoints:
resp = self.client.post(
"/api/profiles",
json={"name": "cloned", "clone_from_default": True},
json={"name": "cloned", "clone_from": "default"},
)
assert resp.status_code == 200
@@ -2604,6 +2685,28 @@ class TestNewEndpoints:
)
assert cloned_skill.exists()
def test_profiles_create_clone_all_from_named_source(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
assert self.client.post("/api/profiles", json={"name": "full-src"}).status_code == 200
source_dir = get_hermes_home() / "profiles" / "full-src"
(source_dir / "config.yaml").write_text("model:\n provider: source-only\n", encoding="utf-8")
(source_dir / "workspace" / "artifact.txt").parent.mkdir(parents=True, exist_ok=True)
(source_dir / "workspace" / "artifact.txt").write_text("copied", encoding="utf-8")
resp = self.client.post(
"/api/profiles",
json={"name": "full-copy", "clone_from": "full-src", "clone_all": True},
)
assert resp.status_code == 200
target_dir = get_hermes_home() / "profiles" / "full-copy"
assert (target_dir / "config.yaml").read_text(encoding="utf-8") == "model:\n provider: source-only\n"
assert (target_dir / "workspace" / "artifact.txt").read_text(encoding="utf-8") == "copied"
def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod
@@ -2620,7 +2723,7 @@ class TestNewEndpoints:
resp = self.client.post(
"/api/profiles",
json={"name": "fresh", "clone_from_default": False},
json={"name": "fresh", "clone_from": None},
)
assert resp.status_code == 200
@@ -3196,6 +3299,56 @@ class TestNewEndpoints:
"top_skills": [],
}
def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(self):
"""Session-only model rows should not render as duplicate zero-token cards.
Direct-provider-on-OpenRouter sessions can leave one row with only
``model`` populated and another row with token/API accounting plus
``billing_provider``. The Models dashboard should show one provider
card, not a real card plus a misleading duplicate empty card.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(
session_id="deepseek-session-only",
source="cli",
model="deepseek/deepseek-v4-flash",
)
db.create_session(
session_id="deepseek-accounted",
source="cli",
model="deepseek/deepseek-v4-flash",
)
db.update_token_counts(
"deepseek-accounted",
input_tokens=20_000,
output_tokens=7_100,
billing_provider="openrouter",
api_call_count=9,
)
finally:
db.close()
resp = self.client.get("/api/analytics/models?days=7")
assert resp.status_code == 200
models = resp.json()["models"]
deepseek_rows = [
row for row in models
if row["model"] == "deepseek/deepseek-v4-flash"
]
assert len(deepseek_rows) == 1
row = deepseek_rows[0]
assert row["provider"] == "openrouter"
assert row["sessions"] == 2
assert row["input_tokens"] == 20_000
assert row["output_tokens"] == 7_100
assert row["api_calls"] == 9
assert row["avg_tokens_per_session"] == 13_550
def test_analytics_usage_includes_skill_breakdown(self):
from hermes_state import SessionDB
+27
View File
@@ -193,6 +193,33 @@ def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_c
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
def test_gated_local_mode_still_defaults_to_home(monkeypatch, tmp_path):
home = tmp_path / "home"
home.mkdir()
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
monkeypatch.delenv("HERMES_MANAGED", raising=False)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HERMES_HOME", str(home / ".hermes"))
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.auth_required = True
web_server.app.state.bound_host = "0.0.0.0"
try:
request = SimpleNamespace(
app=web_server.app,
client=SimpleNamespace(host="10.0.0.2"),
url=SimpleNamespace(hostname="example.com"),
)
policy = web_server._managed_files_policy(request, create_root=False)
finally:
_restore_app_state(prev_auth_required, prev_bound_host)
assert policy.default_path == home.resolve()
assert policy.locked_root is None
assert policy.can_change_path is True
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
client, home = local_files_client
folder = home / "workspace"
@@ -7,6 +7,8 @@ never clobbers a hand-tuned allowlist.
"""
from __future__ import annotations
import argparse
import pytest
from hermes_cli.config import get_env_value, save_env_value
@@ -67,3 +69,44 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
"chat_id": "+15551234567",
"name": "Home",
}
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"ensure_spectrum_enabled",
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "secret_123",
)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+15551234567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "Start the gateway: hermes gateway start" in out
assert "--platform photon" not in out
@@ -154,6 +154,22 @@ def _codex_ack_message_response(text: str):
)
def _codex_final_answer_with_top_level_incomplete_response(text: str):
return SimpleNamespace(
output=[
SimpleNamespace(
type="message",
phase="final_answer",
status="completed",
content=[SimpleNamespace(type="output_text", text=text)],
)
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status="incomplete",
model="gpt-5.4",
)
class _FakeCreateStream:
"""Iterable-only fake for ``responses.create(stream=True)`` outputs.
@@ -1351,6 +1367,92 @@ def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(mo
assert "inspect the repository" in (assistant_message.content or "")
def test_normalize_codex_response_final_answer_overrides_top_level_incomplete(monkeypatch):
from agent.codex_responses_adapter import _normalize_codex_response
assistant_message, finish_reason = _normalize_codex_response(
_codex_final_answer_with_top_level_incomplete_response(
"Briefly:\n\n- I'm Ramsay, your assistant."
)
)
assert finish_reason == "stop"
assert "Ramsay" in (assistant_message.content or "")
def test_normalize_codex_response_top_level_incomplete_without_final_answer_stays_incomplete(monkeypatch):
from agent.codex_responses_adapter import _normalize_codex_response
response = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
status="completed",
content=[SimpleNamespace(type="output_text", text="Partial...")],
)
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status="incomplete",
model="gpt-5.4",
)
_, finish_reason = _normalize_codex_response(response)
assert finish_reason == "incomplete"
@pytest.mark.parametrize("top_level_status", ["queued", "in_progress"])
def test_normalize_codex_response_final_answer_does_not_override_streaming_status(
monkeypatch, top_level_status
):
from agent.codex_responses_adapter import _normalize_codex_response
response = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
phase="final_answer",
status="completed",
content=[SimpleNamespace(type="output_text", text="Interim answer.")],
)
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status=top_level_status,
model="gpt-5.4",
)
_, finish_reason = _normalize_codex_response(response)
assert finish_reason == "incomplete"
def test_normalize_codex_response_final_answer_does_not_override_per_item_in_progress(monkeypatch):
from agent.codex_responses_adapter import _normalize_codex_response
response = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
phase="final_answer",
status="completed",
content=[SimpleNamespace(type="output_text", text="Partial final.")],
),
SimpleNamespace(
type="message",
status="in_progress",
content=[SimpleNamespace(type="output_text", text="")],
),
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status="completed",
model="gpt-5.4",
)
_, finish_reason = _normalize_codex_response(response)
assert finish_reason == "incomplete"
def test_normalize_codex_response_preserves_message_status_for_replay(monkeypatch):
"""Incomplete Codex output messages must not be replayed as completed."""
agent = _build_agent(monkeypatch)
@@ -1418,6 +1520,42 @@ def test_normalize_codex_response_detects_leaked_tool_call_text(monkeypatch):
assert assistant_message.tool_calls == []
def test_scan_for_leaked_tool_call_checks_prefix_window_only(monkeypatch):
from agent.codex_responses_adapter import (
_TOOL_CALL_LEAK_SCAN_LIMIT,
_scan_for_leaked_tool_call,
)
marker = "to=functions.terminal {\"command\": \"pwd\"}"
assert _scan_for_leaked_tool_call(marker) is True
assert _scan_for_leaked_tool_call("x" * (_TOOL_CALL_LEAK_SCAN_LIMIT - 10) + " " + marker) is True
assert _scan_for_leaked_tool_call("x" * (_TOOL_CALL_LEAK_SCAN_LIMIT + 10) + marker) is False
def test_normalize_codex_response_ignores_late_tool_call_marker_past_scan_window(monkeypatch):
from agent.codex_responses_adapter import _TOOL_CALL_LEAK_SCAN_LIMIT, _normalize_codex_response
late_marker = "x" * (_TOOL_CALL_LEAK_SCAN_LIMIT + 100) + " to=functions.terminal {\"command\": \"pwd\"}"
response = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
status="completed",
content=[SimpleNamespace(type="output_text", text=late_marker)],
)
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status="completed",
model="gpt-5.4",
)
assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "stop"
assert assistant_message.content == late_marker
def test_normalize_codex_response_ignores_tool_call_text_when_real_tool_call_present(monkeypatch):
"""If the model emitted BOTH a structured function_call AND some text that
happens to contain `to=functions.*` (unlikely but possible), trust the
@@ -104,6 +104,38 @@ class TestIsThinkingOnlyAssistant:
}
assert AIAgent._is_thinking_only_assistant(msg)
def test_codex_reasoning_items_list_form_detected(self):
msg = {
"role": "assistant",
"content": "",
"codex_reasoning_items": [
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"}
],
}
assert AIAgent._is_thinking_only_assistant(msg)
def test_codex_reasoning_items_with_visible_text_is_not_thinking_only(self):
msg = {
"role": "assistant",
"content": "Visible answer",
"codex_reasoning_items": [
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"}
],
}
assert not AIAgent._is_thinking_only_assistant(msg)
def test_empty_codex_reasoning_items_list_is_not_thinking_only(self):
msg = {"role": "assistant", "content": "", "codex_reasoning_items": []}
assert not AIAgent._is_thinking_only_assistant(msg)
def test_non_reasoning_codex_items_are_not_thinking_only(self):
msg = {
"role": "assistant",
"content": "",
"codex_reasoning_items": [None, "x", {"type": "other"}],
}
assert not AIAgent._is_thinking_only_assistant(msg)
def test_user_message_never_thinking_only(self):
assert not AIAgent._is_thinking_only_assistant({"role": "user", "content": ""})
+111
View File
@@ -12,6 +12,7 @@ the codebase were migrated to the helper; these tests pin that invariant.
"""
from __future__ import annotations
import errno
import json
import os
import sys
@@ -158,3 +159,113 @@ def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:
assert link.is_symlink(), "symlink must be preserved"
assert missing.exists(), "real target should now exist"
assert missing.read_text(encoding="utf-8") == "created-through-link\n"
# ─── EXDEV / EBUSY copy fallback ───────────────────────────────────────────
@pytest.mark.parametrize("fail_errno", [errno.EXDEV, errno.EBUSY])
def test_atomic_replace_copy_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fail_errno: int
) -> None:
target = tmp_path / "config.yaml"
target.write_text("old\n", encoding="utf-8")
tmp = _write_tmp(tmp_path, "new\n")
def fail_replace(src: str, dst: str) -> None:
raise OSError(fail_errno, os.strerror(fail_errno), src, None, dst)
monkeypatch.setattr("utils.os.replace", fail_replace)
assert Path(atomic_replace(tmp, target)) == target
assert target.read_text(encoding="utf-8") == "new\n"
assert not tmp.exists()
def test_atomic_replace_copy_fallback_preserves_symlink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
real = tmp_path / "real.yaml"
link = tmp_path / "link.yaml"
real.write_text("old\n", encoding="utf-8")
link.symlink_to(real)
tmp = _write_tmp(tmp_path, "new\n")
def fail_replace(src: str, dst: str) -> None:
raise OSError(errno.EXDEV, os.strerror(errno.EXDEV), src, None, dst)
monkeypatch.setattr("utils.os.replace", fail_replace)
assert Path(atomic_replace(tmp, link)) == real
assert link.is_symlink()
assert real.read_text(encoding="utf-8") == "new\n"
assert not tmp.exists()
def test_atomic_replace_copy_fallback_preserves_metadata(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
if os.name != "posix":
pytest.skip("POSIX-only")
target = tmp_path / "config.yaml"
target.write_text("old\n", encoding="utf-8")
os.chmod(target, 0o600)
tmp = _write_tmp(tmp_path, "new\n")
os.chmod(tmp, 0o644)
def fail_replace(src: str, dst: str) -> None:
raise OSError(errno.EBUSY, os.strerror(errno.EBUSY), src, None, dst)
monkeypatch.setattr("utils.os.replace", fail_replace)
atomic_replace(tmp, target)
assert target.read_text(encoding="utf-8") == "new\n"
assert target.stat().st_mode & 0o777 == 0o644
def test_atomic_replace_other_oserror_propagates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "config.yaml"
target.write_text("old\n", encoding="utf-8")
tmp = _write_tmp(tmp_path, "new\n")
def fail_replace(src: str, dst: str) -> None:
raise OSError(errno.EACCES, os.strerror(errno.EACCES), src, None, dst)
monkeypatch.setattr("utils.os.replace", fail_replace)
with pytest.raises(OSError) as excinfo:
atomic_replace(tmp, target)
assert excinfo.value.errno == errno.EACCES
assert target.read_text(encoding="utf-8") == "old\n"
assert tmp.exists()
def test_atomic_replace_real_cross_device(tmp_path: Path) -> None:
shm = Path("/dev/shm")
if os.name != "posix" or not os.access(shm, os.W_OK):
pytest.skip("requires writable /dev/shm")
import shutil as _shutil
import uuid as _uuid
other_fs_dir = shm / f"hermes-exdev-test-{_uuid.uuid4().hex[:8]}"
other_fs_dir.mkdir()
try:
real = other_fs_dir / "config.yaml"
real.write_text("old\n", encoding="utf-8")
if os.stat(real).st_dev == os.stat(tmp_path).st_dev:
pytest.skip("/dev/shm is not a separate filesystem here")
link = tmp_path / "config.yaml"
link.symlink_to(real)
tmp = _write_tmp(tmp_path, "new\n")
assert Path(atomic_replace(tmp, link)) == real
assert link.is_symlink()
assert real.read_text(encoding="utf-8") == "new\n"
assert not tmp.exists()
finally:
_shutil.rmtree(other_fs_dir, ignore_errors=True)
+143
View File
@@ -0,0 +1,143 @@
"""Regression: installer fails when the existing checkout has an unmerged index.
A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index
entries (files in a conflicted, "needs merge" state). In that state the update
path's ``git stash`` aborts with "could not write index" and the following
``git checkout <branch>`` aborts with "you need to resolve your current index
first" -- surfacing to GUI/bootstrap users as ``git checkout main failed
(exit 1)`` and failing the whole install at the repository stage.
The ``hermes update`` Python path already clears the conflict with ``git reset``
before stashing (#4735); both installer scripts must do the same.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
pytestmark = pytest.mark.skipif(
shutil.which("git") is None or shutil.which("bash") is None,
reason="needs git and bash",
)
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd,
check=check,
capture_output=True,
text=True,
)
def _extract_autostash_block() -> str:
"""Pull the autostash if-block from install.sh's update_repo()."""
text = INSTALL_SH.read_text()
m = re.search(
r'local autostash_ref="".*?\n fi\n',
text,
re.DOTALL,
)
assert m is not None, "autostash block not found in install.sh"
return m.group(0)
def _make_unmerged_repo(repo: Path) -> None:
"""Leave ``repo`` with a conflicted (unmerged) index, as an interrupted
update would."""
_git(repo, "init")
(repo / "f.txt").write_text("base\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "base")
# Capture the default branch name only after the first commit exists
# (rev-parse on an unborn HEAD errors).
start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
_git(repo, "checkout", "-b", "feature")
(repo / "f.txt").write_text("feature side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "feature")
_git(repo, "checkout", start)
(repo / "f.txt").write_text("main side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "mainside")
# Conflicting merge — exits non-zero and leaves the index unmerged.
_git(repo, "merge", "feature", check=False)
@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo
def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None:
repo = tmp_path / "hermes-agent"
repo.mkdir()
_make_unmerged_repo(repo)
# Sanity: this is exactly the state that breaks `git stash` / `git checkout`.
assert _git(repo, "ls-files", "--unmerged").stdout.strip(), (
"test setup failed to produce an unmerged index"
)
block = _extract_autostash_block()
script = (
"set -e\n"
'log_info() { echo "INFO: $*"; }\n'
"run() {\n"
f"{block}"
"}\n"
"run\n"
"echo BLOCK_OK\n"
)
res = subprocess.run(
["bash", "-c", script], cwd=repo, capture_output=True, text=True
)
# The block must complete (previously `git stash` failed with "could not
# write index" on the unmerged tree).
assert res.returncode == 0, res.stderr
assert "BLOCK_OK" in res.stdout
assert "Clearing unmerged index entries" in res.stdout
# The conflict state is gone ...
assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", (
"unmerged entries should have been cleared"
)
# ... and the local changes were preserved in a stash, not discarded.
assert _git(repo, "stash", "list").stdout.strip(), (
"local changes should be preserved in a stash"
)
def test_install_ps1_clears_unmerged_index_before_stash() -> None:
"""install.ps1 must clear an unmerged index before stash/checkout, and do
so *before* the stash push (order matters the fix is a no-op otherwise)."""
text = INSTALL_PS1.read_text()
assert "ls-files --unmerged" in text, (
"install.ps1 must detect an unmerged index before updating"
)
idx_unmerged = text.index("ls-files --unmerged")
idx_reset = text.index("reset -q", idx_unmerged)
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash, (
"the unmerged-index clear must run before `git stash push`"
)
assert idx_reset < idx_stash, "`git reset` must run before `git stash push`"
def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None:
"""Same ordering contract for install.sh's source."""
text = INSTALL_SH.read_text()
assert "ls-files --unmerged" in text
idx_unmerged = text.index("ls-files --unmerged")
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash
+64
View File
@@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
]
def test_history_to_messages_keeps_reasoning_only_assistant_turn():
# A thinking-only assistant turn (reasoning present, no visible text) is
# persisted and recallable, but was dropped from the resumed session view
# as "empty" -- so it vanished while the agent could still recall it from
# the transcript. Keep it (with reasoning) so the desktop "Thinking…"
# disclosure renders. (#44022)
history = [
{"role": "user", "content": "think about this"},
{"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"},
{"role": "assistant", "content": "here is the answer"},
]
assert server._history_to_messages(history) == [
{"role": "user", "text": "think about this"},
{"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"},
{"role": "assistant", "text": "here is the answer"},
]
def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
# A genuinely empty assistant turn (no text, no reasoning, no tool calls)
# remains filtered out -- the fix only spares reasoning-bearing turns.
history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "reasoning": ""},
{"role": "assistant", "content": " "},
{"role": "assistant", "content": "real reply"},
]
assert server._history_to_messages(history) == [
{"role": "user", "text": "hi"},
{"role": "assistant", "text": "real reply"},
]
def test_history_to_messages_renders_multimodal_content():
# bb/gui preserves image URLs in the resume payload so the desktop
# renderer's extractEmbeddedImages can pull them back out and display
@@ -971,6 +1006,35 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
assert server._sessions[runtime_sid]["model_override"] == captured["model_override"]
def test_stored_session_runtime_overrides_skips_bare_billing_provider():
"""A bare billing bucket ("custom"/"auto"/"openrouter") must not be restored as the
provider identity on resume. A custom endpoint that never used `/model` persists only
`billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
configured" (agent_init treats it as non-routable). A real provider, or an explicit
`model_config.provider`, is still restored.
"""
# Bare "custom" bucket, no explicit model_config.provider: no provider override restored.
ov = server._stored_session_runtime_overrides({"model": "my-model", "billing_provider": "custom"})
assert "provider_override" not in ov
assert ov["model_override"]["provider"] is None
for bare in ("auto", "openrouter", "custom"):
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": bare})
assert "provider_override" not in ov
# A real provider in billing_provider is still restored.
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": "anthropic"})
assert ov["provider_override"] == "anthropic"
assert ov["model_override"]["provider"] == "anthropic"
# An explicit routable provider in model_config wins over the bare billing bucket.
ov = server._stored_session_runtime_overrides(
{"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
)
assert ov["provider_override"] == "custom:myendpoint"
assert ov["model_override"]["provider"] == "custom:myendpoint"
def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
updates = {}

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