Every /model surface parses through parse_model_switch_args. The legacy
5-tuple wrapper had no production caller; its remaining references were two
tests of the tuple shape, an unused import, and two tui_gateway patch()
calls on a function tui_gateway no longer calls.
The en-dash --session normalization those tests covered moves into the
parse_model_switch_args keeper. test_model_switch_once_flags and
test_model_switch_context_offload are removed: the first re-tests the
private parser the keeper already drives, the second the one-line to_thread
wrapper that tests/gateway/test_model_command_context_offload.py proves off
the event loop through the real /model handler.
Each removed test either asserts nothing reachable or repeats another
test's exact body:
- auxiliary_client empty-base_url: body only writes config.yaml; a merge
(890bbbda1f) restored the header of a test b3d4f67b20 had purged. The
non-Anthropic-host test beside it drives the same default fallback.
- background_review no-session-db: _record_review_usage_to_parent swallows
every exception and there is no DB to observe, so removing the guard
still passes; the path has no observable effect to pin.
- code_execution Windows "would have failed" pair: every branch returns,
skips or passes; they exercise CPython defaults, not Hermes code.
- cron test_should_use_direct_api_call_*: byte-identical copy of the test in
tests/agent/test_cron_inline_api_call.py (0213b8b77b had to edit both).
- cron liveness no-multiplexer: identical body to test_no_lock_no_pids_is_false;
its multiplexer patch is never reached.
- browser console/get_images "private URLs allowed": identical to the
local-backend test in each file; both mock the predicate the name promises.
- hermes_state TestTitleUniqueness: the duplicate-title case is a strict
subset of TestSessionTitleLineage::test_unrelated_session_still_conflicts;
the NULL-title case never writes a title.
When active_profile names a deleted profile, the error still said
"Create it with: hermes profile create <name>", a command that exits with
the same error. Name the command that works instead:
`hermes profile use default`. An explicit -p keeps the create hint.
The recovery allowlist checked for the literal string "--data", but the
uninstall subparser accepts argparse abbreviations: `hermes uninstall --dat`
passed the guard and removed the default root's data. `--full` was allowed
outright, and in the default root it rmtree's every other profile under
profiles/ as well, a wider wipe than the --data mode the guard refuses.
Parse the uninstall argv with the real subparser (output silenced; the
handler reports errors itself) and allow it only when neither data nor full
resolves true. `uninstall --help` exits 0 during that probe and is let
through so the handler prints help once instead of help plus an error.
The predicate lives in a topical sibling (main_profile_recovery.py) rather
than growing the main.py facade.
Reuses the repr-field secret-name policy (is_secret_field_name) so token, password, secret and credential-suffixed fields are masked by field in hermes dump, debug share and /debug. The regression test asserts on what actually reaches the paste service first.
Text redaction of str(fallback_providers) missed api_key values that the
runtime still uses (resolve_entry_api_key str()s them): an unquoted numeric
key, a mapping, or a key containing "***" (treated as already masked).
Mask the field before serialising; keep the independent upload-boundary
pass and cover it with #126168's boundary test.
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
The strict URL-credential pass that hermes dump and debug uploads use
redacted X-Amz-Signature but not X-Goog-Signature (GCS V4 signed URLs)
or sig (Azure SAS), so a fallback base_url signed that way still reached
the dump and the upload. The documented promise is signed-URL signatures
in general.
Covers userinfo, ?key=, X-Amz-Signature= and #access_token= through the real config.yaml, hermes dump stdout, the share bundle and the /debug report. Docs (EN + zh-Hans) say the dump redacts credentials in fallback URLs.
hermes dump prints config_overrides.fallback_providers as the raw list,
api_key values included, and the upload-time redaction of hermes debug
share and the gateway /debug report covered only the log snapshots, not
the dump text quoted at the top of every paste.
Force-redact the captured dump like the logs (--no-redact still opts
out), and have hermes dump itself redact fallback_providers so a pasted
dump never carries a key.
update_completion.py arms the pending marker, then imports hermes_cli.main,
which runs prepare_launch. While the updater's lock claim is live the
ancestor check returns early, but an unwritable claim or one past the
20-minute ceiling sends it into _finish_source_update: a full
source_completion tail runs inside the update and clears the marker before
the update's own build has finished.
Exempt both tail scripts through one identity check. The launch check
already resolves its root, so the comparison is exact on the process's
own argv[0], which the processes a tail spawns do not inherit.
- _deepen_shallow_repo marks packs in its finally without a git config probe
that could itself raise and turn a finished unshallow into False; markers
are inert in a clone without a promisor remote.
- Installers warn instead of aborting the stage when a marker cannot be
written, matching the Python helper.
- Docs: the manual commands follow HERMES_HOME, and the missing-object
backfill feeds IDs through `fetch --stdin` so a large list cannot exceed
the Windows command-line limit.
- Test and docstring comments describe the contract, not the incident.
An install whose updater dies on the git 2.53+ pack-objects BUG (#124272)
never fetches the release that heals it: the fetch runs in the installed
code. The installer rerun (install.sh / install.ps1, served fresh) is the
path that runs new code, so when origin is a promisor remote it now marks
the checkout's unmarked packs before its existing-checkout fetch. Marking is
idempotent and never rewrites objects, so it runs up front rather than as a
retry that would first print a failed-fetch report.
git 2.53+ `index-pack --promisor` hands every object outside the promisor
packs to pack-objects, which BUG()s (should_include_obj) on the missing
objects those objects lead to. A partial clone with unmarked packs therefore
keeps crashing its fetches.
The retry added for this ran `git -c remote.origin.promisor= fetch`. That
override does nothing: git registers promisor remotes additively, so the
repo's own `true` still wins (GIT_TRACE on 2.50 and 2.55 shows
`index-pack --promisor` and `filter tree:0` under the override). The retry
only passed when the crashed fetch had already stored the pack; a retry that
has to transfer again crashed again.
- On the crash, give every pack without a `.promisor` marker one, then retry
the same fetch once. The clone stays partial, no refetch, no config change.
If git still crashes, the updater points at the docs section.
- The two fetches that turn a full clone partial now mark the clone's old
packs afterwards: the depth-1 `tree:0` unshallow in
`fetch_full_commit_graph`, and `worktree_ops._deepen_shallow_repo`
(`--filter=blob:none`). git writes the partial-clone config before it
fetches, so this also runs when the fetch fails. Without it, a converted
depth-1 install crashed every later fetch on git 2.55 (3 of 3 rounds in a
local repro).
is_partial_clone_pack_objects_crash() required "index-pack failed" next to the
pack-objects assertion, but git 2.55 builds dropped that string: Homebrew git
2.55.0 (macOS) only reports "fatal: fetch-pack: invalid index-pack output", so
the recognizer always returned False there, fetch_with_partial_clone_recovery()
never retried with the promisor machinery disabled, and `hermes update` exited 1
on the crash (#125138 reports the same bare-assertion failure on 2.55.0.windows.5).
Fingerprint on the assertion text — the only version-stable part of the failure
— and accept the 2.55 wrapper as a terminator. The POSIX/Windows shapes and the
"fingerprint without a terminator" rejection behave as before.
(cherry picked from commit 74103b15ceb3eacb2346fca669c3a3dc81457201)
The salvaged matcher accepted any cmd_update/_cmd_update_impl frame in
hermes_cli.update_cmd/main, so the CURRENT updater's entrypoints (which
carry pre_update_version on opts, not as a local) would also hand off and
exit the process mid-`hermes update`. Extract
_old_updater.in_historical_update() sharing _historical_context's exact
frame matcher (module, name, and pre_update_version in co_varnames) so the
shim and the context detector cannot drift apart.
- tools/lazy_deps: use the shared predicate; accept the constraints/
dry_run kwargs historical updaters pass so they reach the handoff
instead of dying on TypeError; drop the vague message advice.
- tests: collapse 29 parametrize cases to the real invariants and add the
missing one — the frame walk is now exercised through a nested helper
(a getframe(1)-only implementation fails), and a current-updater-shaped
frame (no pre_update_version local) is asserted NOT to hand off.
The standalone ownership test duplicated the parametrized boot migration
(same wrapper, weaker check), was the only test in tests/hermes_cli calling
the private _publish_conveniences, and asserted a mtime-derived "changed"
flag immediately after writing the file, which is flaky on filesystems with
coarse timestamps.
Keep its one unique invariant, another checkout's venv/bin/hermes is never
adopted, by pointing the migration test's existing foreign hermes-agent
wrapper at another checkout's venv. It still also mentions this install in a
comment, so both rejection cases stay covered through public expose_cli().
The previous fix exempted every kind != "skill" item from the browse
cap, on the premise that non-skill items only come from the fixed,
~150-entry COMMAND_REGISTRY. That's not true: plugin-registered slash
commands (PluginContext.register_command) are also classified as
kind "command" but are an unbounded, user-installed source — the same
shape the cap exists to bound for skills. A large plugin install would
have flooded a bare `/` completely uncapped.
Only exempt names actually in GATEWAY_KNOWN_COMMANDS (derived from
COMMAND_REGISTRY) from the browse cap; other kind:"command" items,
including plugin commands, keep the existing 30-item cap.
_rank_slash_completions applied the same 30-item cap to registry
commands and user-installed skills. The cap exists to bound the
skill block (which can grow to hundreds of installs); the command
registry is fixed and curated (~150 entries). On a bare `/`, that
shared cap put /goal, /subgoal, /loop, /queue, /personality and
/debug past position 30 and hid them from the desktop and TUI
browse list entirely, even though they still resolved by prefix.
Only skip the cap for commands when browsing (text == "/"); a
typed search still bounds both blocks.
Fixes#101388
On a compute-host (turn_isolation) session the live agent lives in the child
process, but config.set model either stashed pending_model_switch for a turn
thread that pops-and-drops it against agent=None (busy), or built a second
agent in the serving process and switched that copy while the child kept the
old model (idle). Carry the stash in the turn frame (copied, not popped), let
the child's _ensure_server_session adopt it in both branches, and clear the
server-side stash only after a successful isolated turn.
Rebases tsemana's #79510 onto the post-split modules: the frame key and the
clear-on-success now live in compute_host_bridge.py, adoption in
compute_host.py, and the always-defer condition in methods_config_set.py.
Fixes#79509
Co-authored-by: Tony Semana <tsemana@users.noreply.github.com>
_run_post_turn_followups claimed the session for a goal continuation
checking only running, so a Stop that landed after the model answered
but before the follow-ups ran still chained the next goal turn. Refuse
it while the Stop latch is set; the goal resumes after the next prompt.
Bot live delivery stays ungated on purpose: an envelope is a person's
message rather than an automatic turn, and only a local prompt clears
the latch. A comment at the gate says so.
Stop sets _turn_cancel_requested, but _notif_claim_turn checked only
running, so a background-process or delegation completion arriving during
or after the stopped turn immediately started a new model turn. The claim
now also refuses while the latch is set. That gates completion, delegation
and kanban turns and /loop and /heartbeat ticks; callers already requeue
(or keep the tick due), and the next prompt.submit clears the latch.
Rotation re-inserts the compressor's handoff tail as fresh child rows. Carried
tail dicts arrive without a timestamp, so _insert_message_rows stamped them
with publish-time now(); the parent's durable originals kept their own
timestamps. The two copies then differ in _display_dedupe_key
(role, content, timestamp, tool fields) and _dedupe_display_generations shows
both -> duplicated rows in the ancestor-lineage display read (#59661), once
per segment boundary in a long chain.
publish_compression_child now adopts the durable parent row's timestamp onto
carried rows before insert (best-effort, never overwrites a timestamp the dict
already carries). archive_and_compact gets the same carry for in-place double
compaction, and _insert_message_rows back-stamps the persisted timestamp so
later rotations inherit it. The existing dedupe key and every read path are
unchanged; the collapse happens because identities now agree.
Quad-review follow-up (4 legs ACCEPT-WITH-NITS, P0s independently reproduced):
- _conversation_history_for_session reverts to tip-only: it feeds
conversation_history for session chat / chat stream / v1 runs (model-fed
restore must not regrow what compaction summarized away); the
include_ancestors=True expansion stays on the DISPLAY reads
(_handle_session_messages + web router). Restores the delegation delivery
contract test (probe pinned the model history to the tip segment).
- get_messages now raises ValueError for after_id + include_ancestors
instead of silently ignoring the cursor on a merged-lineage read.
- test_session_db_read_path_split fixture stamps the parent
end_reason='compression' the way a real rotation (publish_compression_child)
does; the old unverified parent link is a shape no production writer
creates and the verified walker correctly rejects.
Verification: test_session_db_read_path_split 9 passed;
test_api_delegation_delivery_contract 2 passed; test_hermes_state.py 270
passed / 1 pre-existing FTS5 base failure (fails identically on 6e07eb4838);
tests/hermes_state/ 310 passed 3 skipped; gateway session-api +
compaction-projection + read-path 44 passed; resume-display + conversation-root
+ tui resume 32 passed; ruff + py_compile + git diff --check clean.
- _resume_lineage_ids walks verified compression links only (each parent ended
'compression', child not an explicit fork), so API forks, reset children, and
subagents stay single-session — fixing the [assistant, user, assistant]
duplicated-answer history ehz0ah reproduced on API forks (#59661 review).
- _handle_fork_session writes _branched_from like the CLI/TUI branch paths.
- _handle_session_messages passes include_ancestors=True (REST session API
half of the original fix, lost in a later squash).
Port of #59661 onto post-#102117 main. The refactor moved get_messages
into hermes_state_messages.py (SessionMessagesMixin) and made the REST
endpoint resolve the resume tip first, so the port adds the
include_ancestors kwarg to the new home — reusing _resume_lineage_ids
(same branch-session exemption as get_messages_as_conversation) and the
IN-clause pattern already used by _fetch_conversation_rows — and passes
include_ancestors=True at the REST messages endpoint and the gateway
API server's history loader.
After a compression rotation the transcript spans parent sessions; the
REST endpoint previously returned only the child continuation, hiding
the pre-compaction transcript while session.resume showed it (#51058).
Tests: 3 new tests in tests/test_hermes_state.py (lineage expansion,
explicit-branch exemption, merged-set paging); 34/34 lineage/branch/resume
suite green.
Same race as the WhatsApp bridge (#127047): a container/supervisor stop
signals the whole process tree, so the Photon sidecar (its own session)
can die before the gateway's stop flow reaches disconnect(), while
_inbound_running is still True. _supervise_sidecar() then reported
SIDECAR_CRASHED and queued a reconnect during shutdown.
Consult the runner's _stop_requested_by_signal, which the signal handler
sets before any stop work runs. A sidecar exit while the gateway keeps
running is unchanged: still a retryable fatal error.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
A supervisor's stop signal (docker stop, s6-supervise, systemd) reaches the
bridge child in the same process group before the gateway's stop flow reaches
WhatsAppAdapter.disconnect(), so _shutting_down is still False when the poll
loop observes the -15 exit. _check_managed_bridge_exit() then reports a
retryable fatal adapter error, the stranded-platform check exits the gateway
with failure, and the supervisor restarts it into a crash loop (#127047).
Also consult the runner's _stop_requested_by_signal, which the signal handler
flips before any stop work runs. A bridge SIGTERM/SIGINT while the gateway
keeps running is unchanged: still a retryable fatal error so the reconnect
watcher revives it.
(cherry picked from commit 252ae311f06e402f9a8ef1482c87aaefe8d9ac39)
preview.read / preview.act / terminal.read / window.read / tour are answered
only by the Desktop window that shows the requesting session (#114981). When
no attached window showed it, every window stayed silent and the agent waited
out the full 30-45s bridge deadline, then got the generic "No preview tab is
open, or the read timed out."
A window not hosting the session now declines with JSON-RPC error 4404
instead of staying silent. The gateway counts a decline as that client's vote,
not as the answer: the request stays open for the owner window and settles
only once every attached answering client declined, with a distinct refusal
("No Hermes Desktop window is showing this chat ... Ask the user to open this
chat in the Desktop app, then retry."). A bystander window therefore still
cannot beat the owner (#113348).
The backend advertises the counting via client.capabilities
(declines_not_shown); the shared channel only sends a decline to a backend
that did, so a new app against an older backend keeps today's silence.
Fixes#119333
A profiles/<name> symlinked to a separate Hermes install is inventoried by the
update plan, left alone by the restart phase, and already classified
'external' by the post-restart fleet matrix. Reconciliation had no such
outcome, so the gateway ended 'unaccounted' and every hermes update exited 1.
Feed the fleet's verified external gateway pids into match_runtime_outcomes:
an untouched gateway in that set is 'external', reported informationally and
excluded from the exit-1 tripwire. No fleet evidence keeps it unaccounted.
Fixes#120240
The idle sweep archives a whole compression lineage. When the chat later
resumed and compressed, the new tip was inserted with archived=0 under the
archived root, and the sidebar admits a lineage by its root's flag, so the
live chat stayed hidden.
Record sweep provenance in a new sessions.auto_archived column. Publishing a
compression child or reopening a session under a sweep-only archive
un-hides the lineage; a deliberate archive (sidebar, API, CLI) clears the
provenance and keeps the chat hidden. Compression children now inherit the
parent's archive state so a manually archived lineage stays uniform.
Fixes#117713
A spilled hybrid got `-ot blk\.\d+\.ffn_.*\.weight=CPU`, which moves every
block's FFN — full-attention blocks included — regardless of how many bytes
actually had to leave the GPU. The GGUF reader now records FFN weight bytes
per block, and spill_overrides picks the fewest recurrent (n_head_kv==0)
blocks whose FFNs cover the decision's spill_bytes, emitting them as an
explicit `blk\.(i|j|...)\.ffn_.*\.weight=CPU` selector. Unknown sizes fall
back to every recurrent block, never a full-attention one.
Based on #120778 by @kvnloo (recurrent-index selector).
Covers the placement half of #113329; the overhead/logits calibration
(RUNTIME_OVERHEAD_BYTES, ub_logits_bytes) is untouched pending measured data.
Refs #113329
An empty ls-remote advertisement cannot tell "merged and deleted upstream"
from "never pushed". The source check now re-pins a missing branch to main
only when the local branch was once published (remote-tracking ref or a
configured upstream) and git cherry finds no commits missing from main.
Otherwise it keeps the branch and reports branch-local-only.
Diagnosis from #105053 by @0xalydev and #105055 by @kokhlo, which targeted
the Electron healer since moved into hermes_cli/source_check.py.
Fixes#105042
Desktop's managed SSH update exports HERMES_UPDATE_CORRELATION_ID and
accepts only a receipt whose correlation_id equals it. The backend never
read the variable, so every successful remote update was reported as
failed and restored. Persist it as correlation_id when the receipt is
created, and keep it across the interpreter handoff.
Idea from @LordMelkor on the issue.
Fixes#101516
TUI/Desktop `_make_agent` (plus `hermes -z` and ACP) built AIAgent without the
resolver's `request_overrides`, so a custom entry's `extra_body` only reached
the agent through the init-time re-derivation, which matches `custom:*` alone.
Auxiliary requests never read the entry's `extra_body` at all. A proxy that
requires a body field (`user`) therefore 400'd in the app and on smart
approval while `hermes chat` worked.
- Pass `runtime["request_overrides"]` at every remaining AIAgent build site
(ACP only when no explicit base_url points at another endpoint).
- `_build_call_kwargs` layers the destination entry's `extra_body` under the
task/caller body, using the agent's own matcher, so primary, retry and
fallback requests each get their own endpoint's body and nothing leaks.
Covers holes 1 and 3 of #103738; hole 2 (matching bare/unprefixed names) is
#124290 and is untouched here.
Under a container terminal backend (docker/modal/...), terminal.cwd names a path
inside the sandbox (/workspace). _completion_cwd host-validated it with
os.path.isdir, failed, and fell back to the gateway's own cwd ($HOME for the
desktop). Every host file then looked "inside the workspace", so file.attach
never staged it into the bind-mounted attachments/ dir and the agent was handed
a host path that does not exist in the container.
- _completion_cwd keeps an absolute container cwd unvalidated when the bound
backend is non-local (matching _terminal_task_cwd), including a named
profile's own container terminal.cwd and a config-only (unbridged) backend.
- _session_is_local_backend reads the effective backend (env or config), so a
container cwd is never "healed" to its nearest host ancestor (/).
- map_cache_path_to_container also matches through a symlinked HERMES_HOME:
@file: expansion passes resolved paths, the mount roots keep the configured
spelling, so staged attachments leaked their host path.
Covers the desktop attachment path of #103147. Typed @file: refs to binary
files outside the mounted dirs still render the host path.
The WS read loop awaited each inline dispatch() before reading the next
frame, so a handler stuck behind a long compaction (or any lock/GIL/DB
stall) left gateway.ping unread. The desktop's 45s heartbeat deadline
then tore down a busy but healthy backend and killed the turn.
Dispatch now runs on a per-connection task that keeps the serial arrival
order; the reader answers pings directly. Teardown still waits for the
in-flight handler and frames read before the disconnect.
Fixes#108325
_build_gateway_agent_history rebuilds a session's own transcript for its
next turn. Rows that pass through whole (tool-calling assistants, tool
results) kept message_uid and the merge witness, but _build_replay_entry
rebuilt plain user and assistant rows from an allowlist that left both out.
A context engine therefore saw the store's uids on some roles and none on
others in the same replayed conversation.
The replay entry now copies message_uid and the merge witness on every
role. It leaves out the tool-call uid maps: a plain row whose unanswered
tool_calls repair pruned can still hold a map, and replaying it would name
calls the row no longer carries.
The absorbed turn's map already had the dict guard; a plugin-built survivor carrying a malformed
map still raised in the per-occurrence expansion. Pre-existing (the old merge raised the same way).
Found by the pre-merge correctness and hermes-specific review of this stack:
- A fold after a row whose one response repeated a provider id (one shared uid) appended the
absorbed turn's uid in the wrong slot. The shared uid now fills each of the row's own
occurrences before the fold appends (per_occurrence_tool_call_uids). A single response keeps one
shared uid for a repeated id: every result carries it, so no call looks unanswered.
- A digest-less re-flush of a restored fold (replay heal, adopt after a digest mismatch) let the
stored pre-fold map overwrite the longer live list, so the later occurrence's results paired
with nothing. A live list that starts with every stored occurrence is kept (stored uid or list).
- A dict that lost its uid map (a clone) rewrote tool_call_uids to NULL. The row-addressed
rewrite now fills stored uids for the calls the dict still names; a call it dropped keeps none.
- append_messages_batch(chunk_rows=...) reset the pairing index per chunk, so a result in the chunk
after its call was stored without tool_call_uid (branch/seed copies). The whole batch is paired
before it is chunked.
- _merge_assistant_into keeps its dict guard on the absorbed turn's map (a plugin-built dict with a
non-dict map no longer raises).
- without_persistence_fields() replaces the two hand-written strips (context-engine selection,
token-estimate fingerprint).
- mint_uid() is the one uid minter; uid_list() the one uid-list normalizer (message_metadata's
_absorbed_uids and the codec's _uid_list share it).
- _live_or_column() replaces four copies of 'live key first, column name second'; the absorbed
list writer takes the dict, not a (live_key, column) pair its one caller always passed the same.
- _restore_row_identity() is the one stored-identity-onto-live-dict step, shared by restore and
the row-addressed rewrite (transcript_repair had a hand copy). Restore skips the decoders for
NULL columns (the common row).
- index_tool_call_uids() returns the variants it named, so the flush walk stops computing them
twice; tool_call_uid_from_history drops guards its one caller can't trip.
- rewind returns the replacement dict (the insert already stamps _row_id and message_uid)
instead of re-querying last_insert_rowid.
- _remember_absorbed_row's folded is required, so every call site states the witness rule.
- The backfill reads/deletes state_meta through _meta_row/_delete_meta; chat_completions strips
MESSAGE_UID by constant.
No behaviour change.
_merge_assistant_into never joins multimodal (list) content, so a non-empty later turn
beside a list (or a list beside non-empty text) is dropped, yet the fold still recorded its
uid in _absorbed_message_uids, a false claim that its text lives on. The merge now reports
whether the later text was kept and the witness follows it; the retired row id is still
recorded.
Reported by @andrexibiza on #126307.
Folding two assistant turns that reuse a provider id (llama.cpp-style constant ids) leaves
both calls in tool_calls, but the {id: uid} union kept only the later uid, so the first
occurrence lost its identity. The id now maps to a list of uids, one per occurrence in
tool_calls order (merge_tool_call_uids); index_tool_call_uids pairs each call with its own
occurrence, so a following result still pairs with the nearest call. Provider ids are never
rewritten. Persisted and restored as-is (the JSON map accepts list values).
Reported by @andrexibiza on #126307.