The content-free attempt record is the one place that says why a
compaction ran and what it did, but it could not answer either question:
- Every automatic caller passed no trigger, so idle, turn-start
threshold, engine preflight, pre-API, post-tool and gateway hygiene
attempts all logged trigger_source=auto. Each call site now passes its
own label; the shared metric still buckets them as auto.
- tokens after, message counts and how the summary was made were
computed and logged as prose but never reached the record. A finished
rewrite now records messages_before/after, tokens_before/after,
tokens_reclaimed (signed; a summary can grow a short transcript),
token_count_method=estimate_rough, method (llm_summary,
aux_fallback_main, deterministic_fallback), items_dropped, and the
tool_results_pruned / reasoning_items_pruned pass counts.
- Attempts that never reached the local compressor returned silently:
the automatic breaker gate (before and under the lease), every Codex
app-server exit, a lease that found the session already rotated, and
an unreadable durable cooldown. Each now logs one record with a
commit_status (blocked, skipped, failed, aborted, committed), a route,
and a class such as blocked:cooldown. These stay out of shared metrics,
which never counted them.
Test doubles for _compress_context gain the trigger keyword the call
sites now pass.
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
(cherry picked from commit 1795993d1a01d91dd76e6ec824cb606f8a03b897)
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.
- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
`Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
these quotes alone and only strips the two that are genuinely redundant, which this commit
strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
The persist / restore / carry logic for session YOLO had grown three copies
(the CLI mixin, tui_gateway, the gateway runner), each with its own key choice,
ordering and error handling. tools/approval_yolo.py now owns it:
- toggle_session_yolo: flip or set, persist BEFORE the live flip (a racing
restore never revives an OFF), stored-copy-aware so a toggle after a restart
turns it OFF; a failed write logs a warning and never blocks the flip.
- restore_session_yolo: re-arm a persisted flag in a fresh process; skipped
under the frozen process --yolo.
- transfer_session_yolo: move the live flag to a continuation id.
- with_session_yolo: a row being created records a live flag (lazy first row,
compression child, /branch).
Each surface keeps only its storage: the session row for CLI/TUI/Desktop, the
routing entry for the gateway. Removed: HermesCLI._transfer_session_yolo /
_persist_session_yolo, GatewayRunner._restore_session_yolo, the TUI-only
restore helper, and the TUI's post-publish set_session_yolo carry (the
compression publish now writes the child row with the flag).
Closes a gap the unification exposed: a CLI manual /compress that rotated the
session dropped YOLO (the next dangerous command prompted, and a --resume of
the continuation came back with it off).
With the live-gateway refusal reverted (#134824), `hermes plugins uninstall`
on Windows still failed with [WinError 5] whenever the running gateway had
the plugin loaded with a file open or a native .pyd mapped: Windows will
neither delete such a file nor rename its directory. The command exited 1
and left a half-deleted plugin.
Removal now deletes what it can. When a held file stops the delete, the rest
of the tree moves out of plugins/ into <HERMES_HOME>/cache/plugin-pending-delete,
which works for mapped DLLs. When the directory itself is held and cannot move,
removal deletes its manifests so the plugin can no longer load. The install
record is dropped in both cases, and the gateway's next start sweeps what was
parked. POSIX is unchanged: a PermissionError still raises.
Live proof: tests/hermes_cli/test_plugin_uninstall_windows_live.py (a real
gateway with plain, open-handle and native-.pyd plugins) fails 2 of 3 on the
revert branch and passes 3 of 3 with this change on windows-latest.
/yolo on a messaging platform only flipped tools.approval's in-memory
set, so any gateway restart (hermes update, crash, systemd restart,
Desktop reopen) silently put the chat back on manual approvals while
the user still believed YOLO was on.
The flag now lives on the chat's routing entry (SessionEntry.yolo,
persisted in gateway_routing like the /model override). /yolo writes it
before flipping the live flag, materializing the entry for a
first-message toggle; every turn re-arms the live flag from the entry it
already resolved; switch_session carries it across compression-tip and
binding repoints; the conversation-boundary security funnel (/new,
/resume, /branch, auto-reset) clears the persisted copy with the
in-memory one so a restart can never revive a bypass the user dropped.
Also corrects the security docs, which still claimed /yolo sets
HERMES_YOLO_MODE.
Remove the global os.environ["PYTHONPATH"] assignment from
_ensure_windows_gateway_venv_imports(). The sys.path manipulation
is sufficient for the current process, and all Hermes child-process
spawners (code_execution_tool, gateway_windows, watcher) already
build their own PYTHONPATH explicitly in subprocess-specific env dicts.
Setting PYTHONPATH globally poisons every non-Hermes Python process
launched from a Desktop session (Unsloth, ComfyUI, etc.), causing
version conflicts and GPU initialization failures.
The TUI builder passed primary_profile(launch_home), which skips the setup
profile; the gateway and CLI builders passed profile_name_for_home(HERMES_HOME),
so /initiate-setup run inside the setup profile told the model to hand off to
the setup profile itself. Use primary_profile on all three surfaces.
Probe (real setup_profile, sandboxed root): HERMES_HOME=default -> default,
work -> work (unchanged); setup profile: old 'hermes-setup', new 'default'.
forget_homes rebuilt each store path from a departed home KEY
(Path(key) / "cron"). cron/AGENTS.md forbids rebuilding a home from a key
(hermes_home_key normcases), and the realpath only follows a symlinked cron/
while the home still exists: a profile whose home was deleted kept its degraded
record, holding the host-wide hermes.cron.store.writable gauge at 0.
register_ticked_homes now records each home's resolved store (new public
store_health.store_key) while the home exists and hands the departed stores to
forget_homes. gateway/cron_store_notices.py uses the same store_key against
record.store (already resolved), so one normaliser decides "is this the served
home's store" everywhere.
Gate r1 (PM12, 2c + quality + 2ab toothless) Low finding.
Follow-up to #133283 (greptile review). Store records are keyed by the
resolved store path, but the notice sender rebuilt the owning profile from
that path's parent. A profile whose cron/ is a symlink to another disk has
a foreign parent, so neither its outage nor recovery notice reached any
home channel; forget_homes had the same mismatch and never dropped such a
departed profile's record.
Both now match by resolving each served home's own cron/ path.
The exec row rebuilt an `import base64; exec(base64.b64decode(...))` string only to feed it back
through the base64 row; decoding the payload directly is shorter and leaves no line that reads like
the base64+exec payload signature the supply-chain scan (rightly) refuses. The test derives the two
re-entry forms from the existing launcher forms instead of re-spelling the cmd-launcher source.
When a launch through the published -c launcher (POSIX shell or Windows .cmd) syncs dependencies
first, venv_sync.relaunch_command re-execs it as `python -I -c "...; sys.argv = [...]; exec(<launcher
source literal>)"`. _BOOTSTRAPS covered the run_module/run_path re-entries but not this exec branch, so
the gateway it started was invisible to every canonical reader: live_gateway_pid_for_home, the fleet
restart, the stale-survivor sweep and update inventory. The new row matches the launcher in place by its
own anchors (readers normalize the literal escapes, so decoding it cannot work) and recurses into the
base64 row for the .cmd form. Extends the #124318 identity table and its test.
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:
- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
preflight compaction were settled against the main model and undoes it before memory sync and
background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
delegations keep using the session model.
Lead fold from the independent review (quality + thermo + 2c + efficiency
lenses, all Low): the fire startup gate and
APIServerAdapter._gateway_is_draining now share runner_is_draining, so a
future drain flag cannot be added to one and missed by the other; the gate
returns early on no runner instead of looping on an unchanging guard. The
cold-adapter test closes its loop and joins its thread, the slow-verify
test keeps the same ordering at a fifth of the wall-clock, and the Chronos
contract doc lists the startup 503.
The startup wait exited as soon as _running turned True. A restart drain keeps _running True
(run_shutdown sets _draining and deliberately leaves _running), and the handler's own drain check
ran before the wait, so a fire whose wait ended in a drain was claimed and run anyway. The gate
now checks the drain flags on every poll before it accepts readiness.
The code-health ratchet holds gateway/platforms/api_server.py (over the 2k target) at its size on
main, and the startup gate's follow-ups grew it by 7 lines. The handler's runner lookup, startup
gate and live-adapter snapshot now resolve in one call, live_adapters_once_started() in
api_server_fire_startup, which returns (refusal, adapters). The facade ends below main (4619 vs
4621). No behaviour change.
A gateway that is stopped during boot never reaches _running, so a fire parked in the startup
wait held its drain reservation for the full budget before refusing, and stalled the shutdown
drain with it. The wait now exits on _draining / _external_drain_active with the same retryable
503 it returns on timeout.
The 7 s startup wait was measured from the gate, which runs after the JWT verify. A slow JWKS
fetch (sync verifiers run off-loop and may hit the network) plus a full 7 s wait could exceed the
dashboard forwarder's 10 s timeout: NAS then sees a 503 and retries while this handler still
claims and runs the job, so it runs twice. The handler now stamps its entry time and the gate's
deadline is measured from it.
The startup gate's refusal was a one-line walrus `if ...: return refusal`, unlike every other
early return in the handler, and the comment explaining why the fire needs the live adapters
(E2EE / relay-fronted platforms have no native credential) was dropped with it. No behaviour
change.
HMAC zero-pads its key, so a whsec_ secret that decodes to NUL bytes
(whsec_AA==) signs exactly like the empty key b"" the previous fold
refuses; the whitespace-only guard let it through. Strip NULs with the
ASCII whitespace before the emptiness check. (Lead fold, 2c lens Low.)
_is_usable_secret only sees the encoded string, so "whsec_" (decodes to b"")
and "whsec_ICAg" (decodes to three spaces) passed it and keyed the Svix HMAC
with a public value: any caller could sign a request. Apply the same
blank-key rule to the decoded key before computing the HMAC.
An empty YAML value (`app_secret:` / `verify_token:`) arrives as None, and
str(None) made the inbound signing key the literal "None". The truthiness
guard in _handle_webhook then accepted it, so anyone could sign a POST with
HMAC("None", body). Same class as the LINE null-secret fix in this stack;
`or ""` lets a null read as unset and reach the existing 503 refusal.
Gate r2 findings 3 and 4. Mutating _dynamic_route_allowed to
`if not effective_secret:` stayed green, because no test reached the
hot-reload guard with a blank or non-string secret. The existing
parametrized connect case now asserts the dynamic-route guard rejects
the same secrets, and it goes red under that mutation (4/4). Also
rename the test and its param to match what they cover (unusable, not
only whitespace-only), and refresh the stale docstring to name
_is_usable_secret.
Fold finding 2 (gate): _is_usable_secret now also rejects non-string
secrets (a YAML int like `secret: 123` fails closed), but the startup
ValueError, the dynamic-route warning, the per-request log and the
comment in _read_authenticated_body still said "no secret" / "missing
or empty". An operator with an int secret would be told it is absent.
All three now say "missing, blank, or not a string"; the connect()
test gains an int row and matches the new wording.
Fold finding 1 (gate, 3 reviewers): is_usable_secret lived in the
experimental gateway/relay/auth.py, so every webhook import pulled in
the relay package (~2.5ms) for a one-line predicate only the webhook
adapter uses. Move it next to _hex_hmac as module-private
_is_usable_secret, revert both relay/auth.py hunks, and move the
parametrized classifier test into tests/gateway/test_webhook_adapter.py.
No behaviour change.
A blank or whitespace-only secret now fails closed at webhook startup,
on the request path, and when a dynamic route is admitted.
(cherry picked from commit 05e918d52c41e905d88e6c0a0f55e7612f4d899d)
Final-stack review (hermes-pr-review 2c) on #133283:
- The tick lock reported its outage under the unresolved profile home (_get_lock_paths), while
every save, probe and grace check uses the resolved store path. With a symlinked HERMES_HOME
(or macOS /var -> /private/var) the lock outage never cleared: writable stuck at 0, no
"recovered" notice, and a skipped one-shot retired as missed. All store_health records are now
keyed through one _key() (realpath). Pinned with a symlinked home; red without it.
- unwritable -> recovered (before delivery) -> unwritable posted nothing: the dropped first
notice left the "recovered" timer pending, so the re-entry cancelled it as if an outage notice
had gone out. A dropped "unwritable" now cancels that timer, so the re-entry announces. The
notice test runs on a manual loop clock (no wall-clock timing; it had flaked once under load)
and pins the sequence.
- cron status printed "0 due run(s) not fired" and an empty summary when the jobs could not be
read from an unwritable store; it now says the jobs could not be read.
/simplify-code quality pass on the final #133283 stack, no behaviour change:
- One null-safe is_recurring in cron/constants.py replaces scheduler_tick's new copy and
unreachable_retry's (whose .get("schedule", {}) raised on a null schedule).
- cron status / cron list share _probe_then_list_jobs (probe, print, guarded read) instead of
two near-identical blocks.
- StoreDegraded.notice_fields and probe_report build their fields through one _report_fields.
- The notice listener's pre-checks no longer repeat deliver()'s store re-check; deliver() is the
single staleness gate (still red when removed).
Greptile r1 on #133283:
- #7: the "recovered" timer started an un-awaited send; if the store failed again while that
send waited on profile loading or its transport, the outage notice could land first and the
stale "recovered" last. Notices for a store now go through one asyncio.Lock in transition
order and re-check the store at delivery, so a notice that no longer matches is dropped and
the outage notice stays the last word.
- #6: t() ran before entering the owning profile's runtime scope, so a secondary profile's home
channel got the launch profile's display.language. The message is now rendered inside that
scope.
- #8: the notice test shortened the window to 0.05 s and slept 0.2 s, against tests/AGENTS.md
(no quiet-runner timing). It now uses a zero window and drains the loop until no timer or send
remains, holds a delivery open with an asyncio.Event to drive the flip-during-send case, and
asserts the full notice sequence.
Review cleanup, no behaviour change:
- warn_store_unwritable only resolved the active store before calling
store_health.note_unwritable; note_unwritable now defaults cron_dir to the active store and
every caller uses it directly (scheduler_tick no longer imports both).
- The advance-skip split no longer zips a parallel bool list twice, and reads
(schedule or {}) so a null schedule cannot raise inside the OSError handler.
- skipped_runs metric: a walrus instead of getattr around an Optional record.
- Notices: runner._served_profile_homes is declared on the reconcile mixin, so no getattr.
- cron status loads the active jobs once (skipped count + summary) instead of twice.