saved_toolset_resolver built the hermes-<platform> bundle set eagerly, so
every migrate_config run (hermes update, config migrate, doctor) and every
config check paid a full bundled+user manifest scan even with no saved
platform_toolsets. The scan is now cached and lazy, like the plugin lookup.
The stale-toolset test stubs the plugin lookups so host-installed plugins
cannot change its verdict.
Follow-up to the config check diagnostics from #127392.
- Saved platform_toolsets are validated by one resolver,
toolset_validation.saved_toolset_resolver, shared with
`hermes config migrate`. The two commands previously disagreed: migrate
flagged MCP server names and plugin-platform bundles (even suggesting
'hermes-teams' for 'hermes-teams'), while config check accepted them.
- A configured MCP server is known even when disabled (enabled: false);
it is inactive, not a typo.
- known_plugin_toolsets is no longer treated as proof that a toolset
exists. It records what a platform was offered at save time, so it hid
toolsets of uninstalled plugins, the stale case this check is for.
- Plugin-provided names are consulted only for names nothing else
explains, so a clean config no longer runs plugin discovery.
- A malformed mcp_servers (a list) no longer crashes the command.
- The top-level `toolsets` check is dropped: the CLI resolves its
toolsets from --toolsets or platform_toolsets, and reads the top-level
key only for the legacy kanban opt-in.
- Disabled platforms are read with the runtime's plugins.disabled reader,
so a non-list value (which the runtime ignores) is no longer reported
as disabling a platform. Required credentials come from
_platform_manifest_env_entries, which now takes optional=False.
- Tests use a synthetic platform manifest instead of the real Telegram
one, and assert config check and migrate report the same names.
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
hermes debug share, gateway /debug, the dashboard share and the desktop
diagnostics upload sent credential query parameters (?token=, ?api_key=,
?access_token=) and user:pass@ userinfo from logged URLs unchanged, to
the paste service or to Nous storage. Log lines keep these values
because log-time redaction leaves web URLs alone, and Firecrawl, for
example, logs the full URL it scrapes at INFO.
The cause is hermes_cli/debug.py:_redact_log_text, the one redactor on
every share path. It called redact_sensitive_text(force=True) without
redact_url_credentials=True, so the strict URL pass that compaction,
memory context, cron errors and the kanban notifier already use never
ran on the upload copy.
Pass redact_url_credentials=True there. Parameter names, hosts and
paths stay readable; values of credential-named parameters and URL
userinfo become ***.
(cherry picked from commit f522171b836bfd842354f5a34c8c2bdd46b39467)
A supervised start that also relaunches onto the store interpreter printed the
notice in both processes; it now prints only after the relaunch decision.
The _supervised_child docstring claimed the neighbouring truthy set, which it
does not match, and prepare_launch said a supervised child "never" retries the
tail although stale dependencies still do; both reworded. The supervised test
also pins a clean stdout and a single notice.
The supervised exemption keeps the pending marker for `hermes update`, but
nothing reported it: a headless host running only the gateway would keep
the unfinished tail (products, skills sync, config migration) indefinitely.
One stderr line per start lands in the unit's journal; stdout stays clean
for commands that emit machine-readable output.
Fold the marker off-value table into the supervised-launch test so 0/false
are proven to still repay the tail through prepare_launch, instead of a
separate test of the private parser. Keeps the stale-dependency sync test.
Review follow-up: the early-bail also skipped the one-shot stale-dependency
sync, so a supervised child on an out-of-date tree booted in place (or
crash-looped under Restart=always) instead of syncing and relaunching.
Gate only the pending-tail half, and parse the markers with the repo's
truthy set so 0/false do not suppress anything.
(cherry picked from commit 0838ce2a916faee5fe7f6a6fa30db3d0a6146647)
A supervised child (systemd unit, launchd plist, Windows task, s6 — all
export HERMES_SUPERVISED_CHILD or HERMES_S6_SUPERVISED_CHILD) is
relaunched on every manager start. prepare_launch() treats the
source-completion-pending marker as a debt to repay immediately, so a
sticky marker re-runs the completion tail (and its environment builds)
on every boot until the disk fills (#123340). Skip the launch-time
repair for launcher-marked children; the tail stays the CLI's to finish
via hermes update / hermes pm install. INVOCATION_ID is deliberately
not read: systemd exports it to every descendant, so an ordinary hermes
command inside a CI runner would skip a legitimate repair.
(cherry picked from commit 4203c068b8b2df5f6b74b4ea610de15bfa2ef54a)
The sentinel local is never read, so a well-meaning unused-variable
cleanup could delete it and silently re-arm the mid-update takeover.
Assert it in co_varnames of update_cmd._cmd_update_impl and
main.cmd_update (unwrapping update_receipt_scope).
The pre_update_version co_varnames check added in af26acab73 rejected the
current updater's entrypoints, but it also rejected historical updaters
from 2026-07-26..08-16, whose _cmd_update_impl/cmd_update never declared
that local — their in-process dependency installs would have continued on
the old interpreter instead of handing off to the takeover child.
Invert the gate: the CURRENT updater declares the
_hermes_current_updater_frame sentinel local in both entrypoints; no
historical on-disk version can contain it. in_historical_update() now
hands off on entrypoint match unless the sentinel is present, restoring
the handoff for every historical window while still refusing current
frames. Verified live for all four frame classes; mutation check: with
the sentinel check removed, the current-updater refusal test fails.
An independent review of every deleted test against its named keeper
found contracts whose only proof had gone, or that no test had ever
pinned. Each restoration is one assertion in an existing test, proven red
by the production mutation named:
- gateway /model reply shows the context length the async resolver
returns (the deleted async-matches-sync test was its only check);
red when resolve_display_context_length_async drops the value.
- parse_model_switch_args("sonnet") has scope "default" (the deleted
tuple test covered the no-flag case).
- shared metrics runtime sends nothing when collection is on but send is
off; red when the gate reads resolved.enabled instead of resolved.send.
- the terminal env-sync test also probes every key gateway.run's bridge
looks up, so a gateway-only key (which the old source parse caught)
still fails it.
- desktop: the deliver ceiling equals the backend's lock wait plus every
attempt, read from the Python defaults.
Also: the tools/bot_relay.py mirror comment names relay-budget.ts, and a
comment orphaned by the Windows encoding-test deletion goes with it.
Each removed test re-asserts a contract a stronger test already owns at
the real boundary; the keeper is named per group.
- api_server CORS: `_origin_allowed` for an allowlisted origin and for an
Origin-less client -> the configured-origin preflight test and every
Origin-less /health request. The Idempotency-Key allow-header assertion
moves into the preflight test instead of running a second identical
preflight.
- api_server room grants: the execution-policy predicate's two outcomes ->
test_api_server_runs' HTTP grant-refresh tests (403 on drift, 200 on the
authorized digest).
- api_server multimodal: the pure input_image canonicalization -> the
/v1/responses test that sends the same parts and asserts the same shape.
- credential pool: matched-hint rotation (401) and second-429 rotation ->
test_credential_pool_routing's real-pool hint test and TestPoolRotationCycle,
which assert the same steps plus the exact rotate kwargs and swap.
- shared metrics: two runtime cases that replay the send resolver's truth
table -> TestSendRequiresCollection; the e2e gzip-retry test (both
attempts compress in the same second, so it cannot see mtime) ->
TestCompression::test_gzip_is_deterministic_across_time; e2e outbox
untouched -> TestOutboxIsNotAQueue.
- mcp oauth: provider reuse across reconnects -> the manager restore test;
the non-tty private predicate -> build_oauth_auth's fail-fast test; the
manager disk-watch test -> the integration test, which gains its one
unique assertion (an unchanged file returns False).
- startup watchdog: disarm-without-arm -> test_disarm_is_idempotent, whose
second disarm runs in the same state and branch.
Each test passed with the behaviour it names removed (proven by mutating
the production owner on the old and new test):
- s6 hooks test_register_noop_on_host: the hook swallows the
AssertionError into a printed warning, so reaching the manager never
failed the test. Assert nothing was printed, as the sibling test does.
- live_system_guard bypass: signal 0 to our own PID passes the guard too.
Assert os.kill is the unpatched builtin.
- retaindb queue_prefetch without a client: a missing guard fails only in
background threads. Assert no prefetch thread was started.
- test_config golden migration parity: drop the `_config_version == 33`
exact-match branch, which cannot run again now the version is 49; the
subset check it fell through to is unchanged.
test_terminal_config_env_sync now drives gateway.run's
_bridge_terminal_config_to_env with every known terminal key instead of
parsing gateway/run.py's source for a dict literal. It asserts each key
reaches the same TERMINAL_* var as the CLI, which the key-set comparison
could not check, and removes the last inspect.getsource in tests/.
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.
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.
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().
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
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.
- Strip persistence-only fields from a select_context() selection: the request copy was stripped before
the hook, but an engine handing back the conversation_messages clones put timestamp, _row_id and the
new ids on the wire (pre-existing for the old fields).
- Every row carries a message_uid whoever wrote it: an AFTER INSERT trigger mints one for a row inserted
without it (an older build writing into a v31 store); the one-time backfill is gated by its own
state_meta marker so a store whose schema version cannot advance (no FTS5) never rescans.
- One merge-witness helper (record_absorbed_message) for every fold of a durable message: alternation
repair's user and assistant merges, the compressor's in-flight restatement onto the carrier, the real
user anchor folded into a scaffolding turn (the anchor's uid leads), micro-compaction's adjacent-user
merge.
- tool_call_uids follows tool_calls: it is a payload column, so an assistant merge's unioned map is
rewritten with the row instead of being overwritten by the stale stored map on adoption.
- A result pairs with the nearest call naming its provider id: each assistant row's ids shadow earlier
occurrences in the batch, restore and live-list indexes, a call row without a map leaves its result
unpaired, and nothing pairs across a user turn.
- A composite rewind returns and installs the replacement row's uid on the live scaffold.
- Tests: import_foreign_history, hermes sessions recover and backup import carry the ids; the ACP
restore test accounts for the uid on restored rows.
(cherry picked from commit b30636a49cb29fdd21ba4100eef9d810897338be)
The fail-open built-in path gated ticks via can_dispatch, so a skipped
tick still recorded a successful heartbeat and cleared the tick error on
the store a live gateway owns. Tick this backend's own store through the
multiplex loop with the same profile_gate instead: a gated-out profile is
neither ticked nor heartbeated.
One _gateway_owns_cron(name, home) predicate now backs every path, so the
fail-open and external-provider paths also stand down for a satellite
served by the live default multiplexer. The external-provider wait probes
once per interval.
Tests: split the external-provider stand-down test and drop the iterator
that hid over-probing; pin the fail-open start kwargs exactly; cover the
satellite gate and the heartbeat through the real built-in loop.
(cherry picked from commit b9250805e7eb951632e9f56e1ce6321e9cbcb395)
The multiplex ticker already yields to a live gateway through its per-tick
profile_gate. The fail-open paths (profile enumeration failure, empty served
set, external provider) still took one ownership probe at backend start and
returned for good, so Desktop cron stayed dead after that gateway stopped.
The built-in single-store ticker now gets the same probe as a per-tick
can_dispatch gate: it stands down while a gateway owns this home, fires once
it stops, and stands down again if it returns. An external provider takes no
per-tick gate, so its start waits, re-probing every interval and honoring
stop_event, until the gateway is gone.
Fixes#126822
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
(cherry picked from commit 4ffe4b3fd68b96fd48c4e8847344a69de7c4f0c2)