Skip to content

chore: sync upstream NousResearch/hermes-agent main (pure sync) - #16

Merged
jigo-hyunjin merged 9830 commits into
mainfrom
sync/upstream-sync
Oct 7, 2026
Merged

jigo-hyunjin merged 9830 commits into
mainfrom
sync/upstream-sync

Conversation

@jigo-hyunjin

Copy link
Copy Markdown
Collaborator

Automated pure sync PR from upstream NousResearch/hermes-agent main.

  • Purely syncs upstream NousResearch/hermes-agent main without any JIGO custom patches.
  • Working tree strictly tracks upstream/main.

Author: jigo-hyunjin

Teknium and others added 30 commits October 6, 2026 13:02
…ease poll

Round 5's Start-MarkerCustodian takes a killed hand-off script's claim over and releases it on its
next 1 s poll after the update ends. Strict run 37393368977 read the marker ~2 s before that
release (custodian removed it at 01:01:32) and called it a surviving marker. The cell now waits up
to 30 s for the release; a genuinely stuck marker lives until the heartbeat ceiling, minutes.
…rols

tests-os[windows] imports only the files list_os_marked_tests.py emits and
keeps `-m "platforms and not integration"` tests its host admits. The replay
regression for the Windows interpreter layout (venv holding python.exe alone,
Git Bash) carried no platforms marker, so the Windows selector omitted the
whole file and green Windows jobs never ran it.

The replay test, the R15 dispatch chain and the disabled/advisory/no-command/
empty-selection controls now carry platforms("linux", "windows"): Linux keeps
its coverage, the Windows selector lists the file (194 -> 195 files) and the
Windows -m filter collects 35 of its nodeids. macOS is left out on purpose:
the replay has not been run on bash 3.2. A routing test pins both gates via
the real selector and its spec semantics.
test_crash_cells carried its own marker parser and identity rule, and it
decides the orphaned_update verdict. It disagreed with the product on three
inputs: a float started_at (product: malformed, copy: live), a create time
1.5 s off (product: live within its 2.0 s tolerance, copy: dead at 1.0 s) and
a run: line before delegate: (product: delegate live, copy: dead, it read
line 4 only). The self-check claimed parity but never called update_lock and
used the test's own pid, which the product judges by exact incarnation.

_marker_live now asks update_lock._parse_marker(...).owner_live() /
delegate_live(); the crash cells run in a pytest process on the same Windows
host as the marker's pids, so the product's process table reads them as the
updater does. The self-check marks a separate live process with an integer
started_at, compares the oracle to judge_marker, and adds the three rows.
The ORPHAN_RELEASE_GRACE poll is unchanged.
…never reach

Replaying the last 131 non-merge main commits (4787e4d) through the
classifier: main selects e2e_upgrade 5 / e2e_desktop_update 3, the CI branch
12 / 10. Four of the seven new hits per lane touched only plugins_cmd.py or
plugins_cmd_install.py. migrate_all_homes reaches them only when a home's
configured memory provider left core (the catalog install); no upgrade or
Desktop update journey configures one, so those suites cannot observe the
change, and the unit lane already runs on every .py edit. Measured on a
fresh home: memory_provider_migration, pm/plugins_state, agent/memory_provider
and plugins/memory run; plugins_cmd_install executes only its module body,
plugins_cmd is never imported.

Kept, because a fresh-home update runs them: memory_provider_migration.py
(migrate_all_homes on every source build), plugins/memory/__init__.py (the
post-update Honcho profile sync), pm/install.py (sealed() /
lazy_installs_allowed() gate every update's default-tool install), and
scripts/build/ (frontend-common.mjs is imported by freshness/web/tui/desktop).
After: 8 / 6. A guard test fails if the journey-home migration starts running
plugin-install code, the signal to route those modules again.
… their siblings

`hermes_cli/_early_recovery_zip.py` (split out of `_early_recovery.py` on the
commit-point PR) is imported by the update path, so a change to it must start
the update pipeline and install E2E suites. Both lists and the install-e2e path
filter now match the `_early_recovery` prefix, which also routes any later
sibling. `remote-lifecycle-v2-marker.test.ts` reads the update-marker corpus
and is now a listed consumer; `remote-update-marker-programs.test.ts` exists in
this tree, so its sibling-PR allowance is gone.
The replayed steps call git. On Linux os.defpath reaches it; on Windows the
replay's PATH only reached it through the located bash's directory, and Git
for Windows ships a bash.exe in usr\bin with no git.exe beside it. The
Windows arm64 lane resolved that bash and 13 replay cases failed with
"git: command not found" (1e547a8 had just put them on the native
Windows lanes). git's own directory now rides along like bash's.
…ings' allowance

Two ratchets this suite arms for its own siblings fired once they landed:

- the Windows pause (NousResearch#132338) now imports gateway/status_inline_source.py
  to recognise inline-bootstrapped gateways, so a change there starts the
  e2e_upgrade suite like gateway/status.py (only two importers: not a hub)
- the marker-corpus consumers NousResearch#132345 and NousResearch#132354 ship are on main, so
  _LANDS_IN_SIBLING_PR empties as its comment asks
…install code

The journey-home guard counted every non-<module> frame in plugins_cmd*.py as
the migration running plugin-install code. d6813a5 gave
plugins_cmd_install.py its first class statement (_ConsentRefusal), whose body
runs at import as a frame named after the class, so a plain import (via
plugins_cmd.py's module-level import) read as a call and the guard failed with
no change in what the update does: migrate_all_homes() still returns [] and
calls nothing in the module.

Frames called by their own file's <module> frame (class statements,
module-level comprehensions) are import-time execution and are skipped; a real
call into an install function is still recorded.
A split model was priced by its first shard alone, so the physics check
and the residency cap admitted giants the card cannot hold: on a 256 GB
box a 175 GB model priced as 6.7 GiB and the router booted with room for
three residents, two of which then co-resided and OOM'd every session.

Sum the tensor table, the host-side embedding duplicate and the per-block
FFN map across every part on disk; architecture metadata still comes from
the first part. The truncated FFN map also disabled spill placement,
which is the lever that would have relieved the pressure.

A part that cannot be parsed is priced out rather than fatal — refusing an
incomplete split to serve remains staged_in's job.
… by all its parts

A header cut short raised struct.error, which no caller of
read_gguf_header catches (they skip a model on ValueError or OSError),
so one truncated file escaped the whole preset pass. A short read now
raises ValueError.

The status row sized a side-loaded split by its first file, which can
be a metadata stub of a few MB; it now sums every part on disk. The
split fixture writes model metadata into part 1 only, as gguf-split
does: a published Flash Next split carries 67 keys in part 1 and only
its 3 split.* keys in parts 2-3.
…loaded

llama.cpp leaves a tensor its architecture marks TENSOR_READ_LAZY in the
file and reads its rows on demand; under the default --lazy-mode auto it
does so for a marked tensor over 4 GiB (b11370: per_layer_token_embd in
qwen4exp and gemma4). Hermes priced those tables as loaded weights, so
Qwen3.8 Flash Next's 26.8 GiB per-layer embedding table counted against
memory it never occupies.

The reader records the candidate tensors per part and applies the
architecture from part 1, so a table in a later split part is found.
profile_from_gguf prices weights without them, and a catalog build
carries lazy_bytes so the same holds before download.
…head

Flash Next's catalog build moves from UD-Q4_K_XL (103.6 GiB, too big for
a 128 GB unified-memory machine) to UD-IQ4_XS: 91.9 GiB on disk, ~60 GiB
loaded once the per-layer embedding table stays on disk. The model has
no MTP layers; unsloth ships the head as its own file, so the catalog
gains an mtp_head companion. It downloads with the model, is priced
before and after download, and only once it is on disk does the preset
add --spec-type draft-mtp with the head as --model-draft (depth 3, from
the vendor recipe). min_engine is b11370, the first pinned build with
Qwen4Exp MTP.

With the projector and head, Flash Next needs ~74 GiB at its native
256K window, so the derived recommendation now picks it on 96 GB
discrete and on 96 and 128 GB unified memory. The catalog test admits
4-bit IQ4 builds as Q4-class, the pane's MTP flag covers a shipped head,
and the Hugging Face browser stops listing mtp-*.gguf heads as models.
llama.cpp's --lazy-mode auto resolves to off on any device without mmap
support, which covers every integrated GPU (b11370, NousResearch#28160). On a DGX
Spark, Flash Next's 26.8 GiB per-layer embedding table therefore loaded
in full (27.2 GiB resident) while Hermes priced it as left on disk.
--lazy-mode on keeps it out (0.4 GiB resident) at the same decode speed
and within 2% of the prefill speed on GB10.

The preset now passes lazy-mode = on wherever Hermes prices the table as
on disk, so the engine's per-device default never decides residency.
AMD and Intel integrated GPUs, where reading on demand halved prefill,
keep the engine default and are priced with the table loaded.
Qwen4Exp's QSA attention scores the whole window each microbatch. Under
the router's unified KV every slot sees the full window, and b11370
reserves ~46 bytes per microbatch token per window token for each
context, device plus host; an MTP head is a second context. At 256K that
is 5.9 GiB per context at -ub 512 and ~23 GiB at -ub 2048, so the
prefill posture Hermes chose on a 128 GB Spark loaded the target and ran
out of memory creating the MTP context.

plan_launch now prices each posture with its own microbatch and context
count, LaunchPlan carries the profile it priced, and catalog entries
name their architecture so the price holds before download. On the
Spark, Flash Next now runs 256K at -ub 512, priced at 83.1 GiB against
81.5 GiB of measured engine buffers.
unsloth's MTP heads mark the MTP block as plain attention (compress ratio
0) beside a QSA trunk. Mainline llama.cpp then builds the k-pool inputs
with nothing reading k_idxs, and the server aborts at load with
GGML_ASSERT(buffer), on b11370 and b11430 alike.

ggml-org publishes a head that loads. On a DGX Spark at 256K and
temperature 1.0 it drafts at 39 tok/s on prose and 32 on code, against
~27.5 without MTP. AssetFile gains a repo so a companion can download
from a repo other than the model's.
The six locale files that carry local-models copy sit past the file-size cap and may only shrink.
Each settings.localModels block moves into <locale>_local_models.ts, as the model-menu copy did,
so the next string lands without growing them. No string changes.
The RTX Spark ships with the 27B as its default model. Flash Next fits its 128 GB pool and stays
available, but Windows backs GPU memory with commit outside the carve-out, so Flash Next loads
only once the carve-out leaves Windows enough memory to back it: with no page file, an 8 GB
carve-out loads it and a 64 or 96 GB one does not.

recommended_entry consults a product-default table before the derived rule, keyed like the
measured-rate table on platform, effective backend and recognized GPU. The derived rule still
decides when the default is ineligible or cannot run resident, under a Vulkan or CPU engine, and
on the DGX Spark. The Recommended badge reads "The default model for this machine, chosen by its
manufacturer."
Older apps refresh catalog.json from main and ignore fields they do not know. Given this
catalog, main's code recommends Flash Next on an RTX Spark and launches it without its MTP head,
with the 26.8 GiB per-layer table loaded and its QSA compute buffer unpriced. Catalog schema 2
makes those apps skip the refresh and keep the catalog they shipped with; they read schema 2
once they update.
…atalog describes

Downloads resolved files from main, so a re-upload on the publisher's side would change the bytes
behind the catalog's exact sizes and the pricing measured from them. AssetFile carries a revision
and the download plan resolves each file at it. Flash Next pins unsloth's weights and projector to
38bb39ee and ggml-org's head to 052beea; entries without a revision still follow main.

Co-authored-by: Ryan Hill <38674843+RyanUnderhill@users.noreply.github.com>
ggml-org's head and unsloth's share a filename, and a download is skipped when its destination
already exists. A machine holding unsloth's head, which aborts mainline llama.cpp at load, would
launch with it instead of fetching ggml-org's. The head now lands as
mtp-Qwen3.8-Flash-Next-ggml-org-Q8_0.gguf.

Co-authored-by: Ryan Hill <38674843+RyanUnderhill@users.noreply.github.com>
Desktop Thinking Off can arrive as {effort: "none"} without enabled=False,
so V4 kept sending thinking:enabled and billed reasoning tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ge (NousResearch#76535)

$focusedWorkspaceCwd's tile branch searched only the paginated $sessions
recents page, so a restored cold tile older than that window (and outside
the project preview caps) resolved an empty cwd and Files showed no
project, even while the row sat in a loaded sibling store. Resolve the
tile's session row through the same loaded-row space the owner lookup and
tileStoredRow already use — recents + cron + messaging + unlisted owner
rows (where a hidden bot chat's resolved row is parked on purpose) + the
project tree's preview/lane rows — keyed by the tile's durable stored
session id, never by persisting the cwd on the tile identity (cwd is
backend-authoritative state another surface can change; the renderer only
caches it). Unresolved secondary focus still fails closed: no rung falls
through to the primary's cwd.
…tes, breaking the projects cycle

The NousResearch#76535 diff grew session-states.ts past its main-pinned FILE_LINES cap
(3264 > 3226) and its ./projects import pulled store/gateway into every
suite that partially mocks the gateway, killing check:test:ui. Offset per
AGENTS.md: the create→foreground owner-hold block moves to topical sibling
session-owner-holds.ts (moved code keeps its cap), and $projectTree moves
to leaf project-tree.ts so session-states reads it without the gateway edge;
projects.ts re-exports it, so every existing import path is unchanged.
… on restart

recover_pending_to_db walks sorted(flush_dir.glob("*.json")) to replay
spool files left behind by a restart. Spool files are named
pending-<uuid4>.json by _write_payload, so that sort is effectively
random: a transcript recovered after a crash comes back scrambled.

The damage is permanent rather than cosmetic. SessionDB restores a
conversation with "ORDER BY id" — AUTOINCREMENT insertion order, never
timestamp — precisely so a non-monotonic clock cannot sort an assistant
tool_calls row after its tool response, "breaking tool-call/response
adjacency and triggering an HTTP 400 on replay". Replaying the spool in
filename order writes exactly that inversion into the row ids, so the
session errors out on the next turn.

The payloads already carry the ordering fields: spool_dropped_transcript_
message stamps every one with "ts" and a monotonic "seq". The sibling
consumer of the same spool in this file, drain_transcript_spool, already
sorts on them via sorted(entries, key=lambda e: e[:3]); only the restart
path ignored them. Order on (ts, seq, filename) so the live drain and the
cross-restart drain agree.

Ordering by the payload rather than by a new file-naming scheme also
recovers spool files that were written before this change, which are the
ones an affected user already has on disk.

Payloads that cannot be parsed keep their previous treatment: they sort
last, are handed back unparsed, and are re-read inside the loop so the
existing handler reports and preserves them unchanged.

(cherry picked from commit 0e46604)
(cherry picked from commit c043f01)
…pooled messages

The cross-restart replay of a cap-dropped transcript message forwarded
only four fields to append_message — session_id, role, content, timestamp
— and then unlinked the spool file, making the loss permanent.

Everything else on the message was discarded: tool_calls, tool_call_id,
tool_name, the reasoning columns, codex items, platform_message_id,
observed, and the api_content sidecar. append_message already accepts all
of them, and the payload already carries them, because
spool_dropped_transcript_message writes the full transcript message dict.

Two of the dropped fields are load-bearing rather than decorative. Losing
tool_calls/tool_call_id orphans a tool result from the call it answers,
which is the adjacency SessionDB's "ORDER BY id" exists to protect.
Losing api_content contradicts the requirement stated at the live writer,
that the sidecar "must survive any gateway-side persistence path or the
next turn's replay diverges at this row" — and this is such a path.

The same spool is drained two ways: by drain_transcript_spool during live
operation, which replays through SessionStore._append_transcript_message
and keeps every field, and by this function after a restart. Same files,
same payloads, different fidelity. Mirror _append_transcript_message field
for field so the outcome no longer depends on whether the gateway happened
to restart, including its role gate on the assistant-only reasoning
columns and its message_id fallback for platform_message_id.

Fields are whitelisted explicitly rather than splatted from the message
dict: the payload is arbitrary JSON from disk, and an unexpected key would
raise TypeError and abort the whole recovery pass. content is now passed
through as-is instead of being coerced to "", since an assistant tool-call
row legitimately has no content.

(cherry picked from commit 99ef8fb)
(cherry picked from commit 27ee00d)
…pend

Ordering the spool files fixes the happy path only. If append_message
fails partway through — the DB is still unhealthy, which is the situation
that produced the spool in the first place — the loop logs the failure,
keeps that file for a later retry, and then carries on and writes the
messages that come after it.

Those later messages land now; the failed one lands on some future start.
Because SessionDB orders a conversation by AUTOINCREMENT id, the retried
message then gets a HIGHER row id than the messages it originally
preceded, and the inversion this pass just prevented is written to disk
anyway — this time permanently, since both files are gone.

drain_transcript_spool, the live drain of the same spool, already states
the rule: "On the first replay failure the drain stops and remaining
files are kept for the next attempt (the DB is likely still unhealthy)."
Apply it here too.

The block is per-session rather than global. Replay order is only defined
within a session, and this function drains every session's spool in one
pass, so a single unhealthy session must not strand the others' messages
on disk. Non-transcript pending payloads are unaffected.

(cherry picked from commit 5ea5104)
(cherry picked from commit a959154)
Names the spool files so that filename order is the exact reverse of drop
order, which is what uuid4 names produce on average, and asserts the
replay comes back in drop order. A second case gives three payloads the
same one-second ts so only the monotonic seq can separate them.

Both fail on the previous sorted(glob()) implementation. A third case
pins the pre-existing treatment of a corrupt payload — reported by the
loop's own handler and left on disk — so the new ordering pass cannot
silently swallow one.

Lives in its own file rather than tests/gateway/test_shutdown_flush.py to
keep the restart-recovery cases together with the spool fixtures they
need.

(cherry picked from commit 555d871)
(cherry picked from commit c372b25)
…overy

Round-trips an assistant tool-call row carrying every field the live
writer persists and asserts each one reaches append_message: tool_calls,
tool_call_id, tool_name, the reasoning and codex columns,
platform_message_id, observed, timestamp, and the api_content sidecar.
Also pins content=None passing through uncoerced, since an assistant
tool-call row has no content, and the role gate that keeps the
assistant-only reasoning columns off a user row.

A separate case covers a failed replay: with the first of two messages
for one session rejected, neither is written and both spool files stay on
disk, while a third message for an unrelated session still recovers.

All but the two behaviour-preservation cases fail on the previous
implementation.

(cherry picked from commit 869acc6)
(cherry picked from commit e02d9fc)
… absent

_transcript_append_kwargs chose the spooled message's timestamp with
`message.get("timestamp") or payload.get("ts")`, carried over from the
code this PR replaces. Truthiness is the wrong test: epoch 0 is a valid
timestamp and would be silently rewritten to the payload's ts, which is
the fidelity loss this PR exists to remove.

Fall back only when the field is genuinely absent.

(cherry picked from commit 62206b0)
(cherry picked from commit a7b6828)
…uilder

Review follow-ups (teknium1):

- One kwargs builder, two callers. transcript_append_kwargs() in
  gateway/session_transcript.py builds the append_message kwargs, and both
  _append_transcript_message and the spool's restart replay call it, so the
  restart writer cannot lag the live writer again (display_kind and
  display_metadata were added after NousResearch#84785 was written). fallback_ts applies
  only to a message with no timestamp; the live writer passes none, so its
  rows are byte-identical.
- A failed replay says what it held back: one INFO line per pass with the
  file count per blocked session, so a spool dir that never empties has a
  reason in the log.
- seq is per process and ts has one-second resolution; the sort key now
  says so where it reads seq.
- Tests fold to three invariants: drop order beats name order (seq breaks a
  same-second tie), a replayed row is the row the live writer writes, and a
  failed replay holds back that session's later messages only. The two that
  already passed on main (a corrupt file is kept; the payload-clock
  fallback) are dropped; the fallback is asserted inside the fidelity test.

(cherry picked from commit e2bacb0)
Teknium and others added 27 commits October 7, 2026 01:07
…n sibling

restorePendingApproval joins restore-pending-clarify and
restore-pending-connection as a topical sibling of use-session-actions,
so the resume module sheds lines before the next fix touches it
(code-health file cap). No behaviour change.
Branching the open chat calls navigate(child) and then resumeSession(child)
in the same tick. resumeSession captures the route token at entry, but
the router only publishes the new pathname on the next render, so the
token it captured still named the PARENT. When that render landed while
resume was awaiting resolveStoredSession/gateway, isCurrentResume() saw
the token change and returned, abandoning the resume before it bound the
warm branch runtime. The pane was left with the child selected, no
active runtime, an empty transcript and a loader for a row with history;
use-route-resume saw nothing to heal (selection already matched the
route, creatingSessionRef had suppressed the pathname edge), so the
child stayed on the spinner with no composer until the user switched
away and back.

isCurrentResume now treats the route arriving at the session being
resumed as still current (resumeRouteStillCurrent in
session-context-drift.ts); a route to any other chat still supersedes
the resume exactly as before.

De-flakes Desktop core E2E lineage-sidebar ("a branch child is titled
and is its own row"), which hit this ~2 in 10 runs.
… (default lane)

NousResearch#134320 shipped lmstudio / tencent-tokenhub profiles with empty env_vars. The
provider-catalog e2e harness reads key vars from the profile only, so main's
`Python tests / e2e` went red (56 KeyError: '' errors + 2 switch failures);
NousResearch#134385 restored the env vars. The PR's own e2e lane had been skipped by the
change classifier, which is how it merged green.

This invariant runs on the default test lane, which is never skipped: every
api_key profile backed by an api_key PROVIDER_REGISTRY row declares that row's
key variable first in env_vars. It fails on the pre-NousResearch#134385 tree naming both
profiles and passes on main.
…ting cache still egresses

(cherry picked from commit fb4351a)
…gateway's adapters instead of running with none

(cherry picked from commit cd1314e)
… the timing assertion

(cherry picked from commit 3c52910)
…er timeout and retries after 60s

(cherry picked from commit e5d54c2)
… and keep the live-adapters WHY

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.
…low token verify cannot outlast the forwarder

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.
…ateway starts draining

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.
…t_delivery

The fixture hand-assembled a _TargetDelivery (a Slack origin paired with a hard-wired Telegram
target, a duck-typed transport, a hand-set origin_user_id), so it asserted a shape the real
prologue never produces. The targets now come from _prepare_target_delivery with a live relay (or
native) adapter, so origin matching, transport resolution and origin_user_id are the production
ones.
…apters resolve in the fire-startup sibling

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.
…ay finishes starting into a drain

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.
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.
…ead, not timers

Review (greptile, tests/AGENTS.md): the startup and drain tests changed
the runner after fixed sleeps, so a loaded runner could finish startup
before the handler reached the gate and the slow-verify test could see
a 202. The runner now signals when the gate first reads _running, and
each test changes it only then. The slow-verify case completes startup
at that first read, so only a budget measured from handler entry
refuses.
…into route_binding

try_activate_fallback's in-place swap (client, pool, compressor, reasoning, extra_body) and
restore_primary_runtime's reinstall are now agent/route_binding.py::bind_route_entry and
::reinstall_primary_runtime / ::reinstall_runtime_snapshot, so a per-turn route can reuse the
same binder instead of a third copy. No behaviour change.
…own model

Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (NousResearch#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.
…evel where off is refused

auxiliary.voice_chat.reasoning_effort ships as "none", and it applies even when the slot is on
auto (the session's model answers the spoken turn with reasoning off; typed turns keep their
effort). Routes that cannot switch reasoning off get their weakest accepted level instead:
Responses ladders without "none" (gpt-6-astra, gpt-6.1-sol -> low), mandatory-thinking Claude,
and routes the catalog or an earlier 400 marked mandatory. A disable the voice model rejects at
runtime is remembered per route for the next voice turn, and the rejection flags never leak
between the voice route and the main one.

"Inherit" in the dashboard/Desktop writes an explicit "" so the slot default does not come back;
both resets restore the shipped default.
The overlay binds its Escape handler in a passive effect keyed on the failed state, which can
flush after "Installation failed" is already painted. The test pressed Escape once right after
findByText, so on a loaded runner the key landed before the listener existed and the overlay
(correctly) stayed up for the whole waitFor. Proof: delaying the listener by 80 ms reproduces the
CI red on the old test and passes the new one.
…earch#115104)

MemoryManager forwarded turns, transcripts and tool output to external
memory providers without the egress scrub, so a key echoed into tool
output was archived verbatim in the provider's store.

Every fan-out now goes through redact_for_egress (the chat/cron delivery
scrub; holds with security.redact_secrets off, fails closed): sync_all
(turn strings + messages), prefetch_all / queue_prefetch_all,
on_turn_start, on_session_end, on_pre_compress (both transcripts),
on_memory_write (content + metadata), on_delegation and provider
handle_tool_call args. Nested dicts/lists (tool-call arguments,
multimodal parts) are walked; data: URLs pass; the live transcript is
never mutated. A value masked anywhere in one hand-off is masked
everywhere in it, so a key the model repeats bare in prose is caught.

redact.py: the JSON-field rule now also matches inside JSON-encoded
strings (tool results wrap config dumps in escaped JSON), which is how
the reported opaque AQ.-prefixed key slipped past.

Co-authored-by: Teknium <teknium1@gmail.com>
…tor-managed ones

The self-improvement review fork refused every edit/patch/write_file/remove_file on a skill
without `created_by: "agent"`, so lessons about hand-written, URL-installed, foreground-created,
bundled, hub, external or pinned skills were dropped with only a WARNING line (77% of refused
review writes on the NousResearch#134289 reporter's install). The prompts then steered the fork to a
different umbrella or a new near-duplicate skill.

Ownership now gates only delete/archive: the review may improve any skill it learns from (still
read-before-write, ledgered, and staged when skills.write_approval is on), while autonomous
archival stays limited to curator-managed, unpinned, non-bundled/hub/external skills. The review
prompts drop the protected-skill lists and say never to delete.

Fixes NousResearch#134289
Windows 11 25H2 Settings records the default browser under
UrlAssociations\https\UserChoiceLatest\ProgId and stops updating the legacy
UserChoice key, which UCPD.sys also write-protects. Reading only the legacy
ProgId made real-profile browsing refuse a correctly configured Edge/Chrome
default ("not a supported Chromium browser"), with WinError 2 when the key
exists without a value, or picked a stale browser when it still held one.

Resolve the effective ProgId via AssocQueryStringW(ASSOCSTR_PROGID) first,
then fall back to UserChoiceLatest\ProgId, then the legacy UserChoice value.
Classification (channels fail closed) is unchanged.

Salvages NousResearch#106971 (Shell API lookup); the UserChoiceLatest fallback follows
NousResearch#108064 and the reports on NousResearch#107854.

Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
…ult browser

Get-DefaultBrowserExe read only the legacy UserChoice ProgId, which Windows
11 25H2 Settings no longer updates, so the update hand-off fell back to the
WinForms card (or a stale browser) on machines whose real default is
Chrome/Edge. Read UserChoiceLatest\ProgId first, legacy UserChoice second.

Fixes NousResearch#108051. Follows NousResearch#108062 / NousResearch#108064.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
@github-actions

ghost commented Oct 7, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 42789e1 — chore: sync upstream NousResearch/hermes-agent main (pure up

❌ Job failures

Check contributors / check-attribution · View job

Job Check contributors / check-attribution failed.


Python lints / code health ratchet (blocking) · View job

Job Python lints / code health ratchet (blocking) failed.

@jigo-hyunjin
jigo-hyunjin merged commit 1ef6b4f into main Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.