Commit Graph
1108 Commits
Author SHA1 Message Date
Teknium 765933299d fix(compression): a prepared summary is never redone over a moved tail cut
With compression.prepare_ahead, compress() re-planned the tail cut and threw the
completed background summary away whenever the fresh cut landed before the
summary's boundary (the no-LLM prune made tail rows cheaper, tool-group
alignment moved the cut), then wrote a second summary inline while the user
waited. That breaks the one-summary rule: once the background summary exists it
is used, and everything after its boundary is the tail.

The splice check no longer consults the fresh cut at all. A summary is still
discarded only when the messages it covers changed (rewind, shape change, other
session, moved head). The pass planned its own window with a full tail, so the
tail is never empty. The now-unreachable inside_tail reason leaves the
(unreleased) prepared-compaction metric contract.
2026-10-26 00:10:23 -07:00
Teknium b991bd203b fix(telemetry): exit flush applies each profile's own send consent and collection gate
Review findings 1 (critical) and 2 on #144850, plus the send-pass sibling.

A multiplex gateway or compute host exits from the launch profile's scope, and
the os._exit flush (like main's atexit hook, which has the same flaw) shut down
every profile's runtime under it: profile B (send: false) got the launch
profile's send=True reconciled into its store, an open-ended consent window and
a send pass over its outbox; a profile that turned collection off since its last
turn still had its open sessions written at exit.

- _Runtime.shutdown binds its own profile home (host.profile_key) for every
  caller (atexit, shutdown_runtimes, tests) and checks enabled() inside that
  scope first: a revoked runtime is deactivated, dropping its data unexported.
- _flush_and_export exports under the owner's scope, whoever triggered it.
- The send pass thread inherits that scope (spawn_context_thread): on main it
  ran on a bare Thread, so still_consented() re-checked the LAUNCH profile's
  consent (B's pass read A's send:false; a B revoke would not stop its pass).
- Tests: wait_until replaces the sleep-poll; one three-profile invariant test
  through the real gateway exit funnel.
2026-10-25 23:13:53 -07:00
Teknium d773589f7c fix(compression): every prepared summary ends counted as used or discarded
Re-review findings:
- a summary take() handed to compress() that the compaction then aborted or no-opped before
  adopt() spliced it was counted nowhere; it is now counted once at the next touch as
  discarded/compaction_aborted;
- a session boundary landing after the pass published but before the waiting compaction woke
  read as a failed pass (a second discarded row); the wake now reads the entry and the cancel flag
  under one lock, so the boundary's row is the only one.
2026-10-25 22:55:11 -07:00
Teknium 5bdf1de16f fix(compression): count each prepared summary once, at the splice
Independent review of the telemetry found:
- a session boundary that woke a waiting compaction was counted twice (the boundary's discard
  plus the woken wait's 'discarded_while_waiting'); the boundary's row is now the only one and the
  dead reason code is dropped from the vocabulary;
- 'used' was recorded in take(), before a stale-attempt abort could still drop the summary; it is
  now recorded by adopt(), the splice itself, with the wait carried on the entry;
- the cancel reason is set under the lock, so a pass finishing in that gap reports the real reason;
- skipped_busy counts once per refused window instead of every tool batch.
2026-10-25 22:55:11 -07:00
Teknium 77af0690a8 fix(compression): one-shot runs start no wasted pass; releasing an agent cancels its pass
The live matrix on real processes found two ways a prepared-compaction pass was paid for and never
usable:

- `hermes chat -q` / `-z` runs end with their turn (the gateway evicts the agent right after the
  reply), so a turn-end pass there was always cancelled ~0.5 s later. One-shot sessions now join
  cron runs and delegated children in skipping turn-end passes.
- Releasing an agent without a session boundary (gateway cache eviction, a Desktop rewind,
  managed-worker retire, close()) left a running pass billed to completion. release_clients() and
  close() now discard it (shared-metrics reason `agent_released`).

Pass log lines now carry the session id, so concurrent sessions in one gateway are attributable.
2026-10-25 22:55:11 -07:00
Teknium 4ea24baeb1 feat(shared-metrics): count prepared-compaction adoption and outcomes
Opt-in shared metrics could not say whether anyone turns on
compression.prepare_ahead, or whether the passes it starts pay off.

- hermes.install.snapshot gains compression_prepare_ahead (on/off), read
  from the profile config with the agent's own truthiness. Pre-upgrade
  snapshot rows keep a legacy field set and the v4 schema field is
  optional, so they still validate and package.
- New closed-enum counter hermes.prepared_compaction.count (mark name =
  metric name, projected by the decision-mark table): event, origin,
  reason, failure_class (compression vocabulary), wait_bucket.
  record_prepared_compaction() takes raw values, normalises inside, is a
  no-op unless collection is on and never raises.
- Vocabularies live in shared_metrics_prepared_compaction.py (the
  contract sits at the 2,000-line cap); the contract imports it.
- v4 schema extended in place (additive), docs row + snapshot bullet,
  relay smoke asserts the snapshot field.
2026-10-25 22:55:11 -07:00
Teknium 3d8fe711ab fix(compression): a prepared summary never covers unpersisted history it cannot vouch for
Review follow-up (Greptile on 41ac847b3ee): caller-supplied conversation_history
that is never persisted carries no message_uid, so the structure-only check
accepted a dropped user/assistant pair once the transcript regrew to the same
shape, and the old summary replaced messages it never summarized.

Each message identity now also carries a text digest. A message whose uid is
present on both sides is still matched by uid alone, so plugin text rewrites
of persisted messages keep the summary; a message without a uid on one side
is matched by text.
2026-10-25 22:55:11 -07:00
Teknium c2383a6806 feat(compression): one prepared summary per compaction, newer messages join the tail
Maintainer ruling on the prepared-compaction design, plus the review findings
(ours and Greptile's) on #144684:

- One pass per compaction. The extension passes (a new summary call every ~8k
  tokens of growth) and the "stale after one tail budget" check are gone: the
  summary splices at its own boundary and every message after it, however
  many, stays verbatim in the tail. No second summary is ever paid for.
- The splice check compares message structure, not text: role, tool call
  ids/names, tool result ids, plus the durable message_uid where both sides
  carry one. Plugins that rewrite text in place (redaction, pre-compaction
  notes) no longer throw the summary away; a dropped/inserted message or a
  rewind into the window (new message_uids) still misses. This also settles
  the fingerprint gap on input_text/output_text parts: text is not compared.
- A pass that recovered on the main model after the summary model failed now
  hands that over on a splice, as an inline call would: summary_model goes
  sticky to the main model, and the "configured compression model failed"
  warning reaches the user. The attempt telemetry carries the pass's aux call
  (model, tokens, duration, fallback) and a new prepared_summary flag.
- The first compaction that needs memory-provider context stops further
  passes for the agent; each would have been discarded and paid twice.
- Turn-end passes are skipped for cron runs and delegated children, whose
  sessions end with the turn.
2026-10-25 22:55:11 -07:00
kshitijk4poor 0d37f9c2d7 fix(compression): end cancelled passes quietly and drop stale candidates
Round-two review of the cancel and fast-path commits:

- A cancelled pass's stream raises AuxiliaryExplicitCancellation, a
  BaseException that _generate_summary and _run_pass did not catch, so
  every superseded pass printed "Exception in thread compaction-prepare"
  to stderr (seen in the live probe). _run_pass now catches it and logs
  the cancel.
- take() snapshotted the candidate at its start; a pass that published
  while compaction was checking that snapshot survived the compaction.
  It never spliced (its fingerprint no longer matched), but the
  prompt-size skip could then keep it and start no pass for a cycle.
  take() now clears every candidate on exit.
- The prompt-size skip measured growth since the plan, so once the
  prompt grew past the threshold while the window gap stayed small,
  every tool batch re-planned again. Keeping a candidate after a full
  check now re-arms the skip from that check.

Tests: one stub models the aux client ending a cancelled stream, the
local wait helper gives way to tests.waiting.wait_until, and the
skipped small-window-delta path gets its own test. Docs and the config
comment name the discard case.
2026-10-25 22:55:11 -07:00
kshitijk4poor 7558b1c412 fix(compression): cancel a prepared pass nothing can use any more
A pass that compaction or a session boundary superseded kept streaming
its summary to the provider's own ceiling: billed, then thrown away. The
same happened when a waiting compaction gave up at the host deadline or
on a host cancel, so the summary was paid for twice (once in the
background, once inline). Live probe: after the deadline cut a wait
short, the pass still completed 4 s later and was billed (2 aux calls).

Each pass now carries a cancel event, wired into the existing aux
hard-cancel path (aux_interrupt_protection(cancel_event=...)) so the
stream is closed. take() sets it whenever it consumes or supersedes the
pass; discard() sets it on reset, end and rebind. A compaction waiting
on a discarded pass wakes at once instead of waiting out the pass.

Also:
- the waiting compaction installs its progress hooks before the rest of
  the setup; resolving the aux timeout loads config on first use
  (~0.8 s), and progress streamed in that window was dropped;
- a miss now says why: deadline, host cancel, discarded, or the pass
  failed (every miss used to read "did not finish in time");
- the config comment mentions the wait.

Probe after the fix: deadline miss -> the pass exits ~0.1 s later, 1
aux call billed; reset during the wait -> the wait returns at once.
2026-10-25 22:55:11 -07:00
kshitijk4poor cc940eeb6a fix(compression): let prompt reading in a waited-on pass extend the deadline
Inline, a llama.cpp server reading a long summary prompt pushes the host's
compression deadline back (compression_deadline_scope installs a
prompt_reading_hook that extends the fence). A compaction waiting on a
prepared pass lost that: the pass thread had no reading hook, and the wait
read the deadline once. A slow-but-healthy local prefill could hit the
original ceiling and fall back to an inline summary that pays the same
prefill again.

The pass now installs a reading hook that forwards to the waiting
compaction's hook, and the wait re-reads the live host deadline on every
poll.
2026-10-25 22:55:11 -07:00
kshitijk4poor d2d1824fdf feat(compression): wait for a running prepared-compaction pass at the threshold
When automatic compaction fires with no completed candidate but a pass is
still writing a summary whose window fits, compaction waited for nothing and
wrote the same summary again inline, so the user paid the full summary
latency and the background call was thrown away.

Compaction now waits for that pass. Each streamed chunk of the pass touches
the compacting thread's aux progress hook, so the host's idle watchdog treats
the wait like an inline summary stream. The wait ends at the host's stream
deadline (the compression aux timeout when none is installed) or when the
host cancels, and compaction then summarizes inline as before. A running pass
whose window no longer fits is not waited on. Annotations in the module and
its tests move to builtin generics.
2026-10-25 22:55:11 -07:00
Hugo Alves PereiraandClaude Opus 5.5 9212396c4e fix(agent): bound prepared-compaction staleness, size band from trigger, discard on rebind
- A candidate only splices when the messages between its boundary and the
  fresh tail cut fit in one more tail budget; a staler candidate misses and
  compaction summarizes inline.
- The preparation band is 12% of threshold_tokens, not of the context
  window, so a small absolute trigger on a large window no longer starts a
  pass on every turn.
- bind_session_state() discards the candidate and any running pass when it
  binds a different session id (/resume, /branch); same-session rebinds
  keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:55:11 -07:00
Hugo Alves Pereira 32a636bc8d feat(agent): opt-in prepared compaction (compression.prepare_ahead)
With compression.prepare_ahead (default false), once the provider-reported
prompt size is within 12% of the context window below the compaction
trigger, the turn loop starts a background pass before a tool batch runs
and at turn end. The pass plans the same window compress() plans and runs
the same summariser on a copy of the compressor and of the window rows.
The pass itself never rewrites the live transcript, system prompt or
prompt cache; the transcript keeps growing normally meanwhile.

At the next automatic threshold compaction, compress() splices the
finished candidate when the head boundary is unchanged, its boundary is no
later than the fresh tail cut, and the transcript up to that boundary is
unchanged (ignoring what the no-LLM prune rewrites). Messages after the
candidate boundary stay outside the summarized region and go through the
existing tail handling. Everything else, including a pass that has not
finished, manual/focus compaction, overflow recovery and memory-provider
summary context, compacts inline as before. A running pass is never
waited on and loses the right to publish once compaction or a session
boundary overtakes it. A failed pass backs off further passes without
touching the live failure cooldown.

State is per compressor and in memory only. Plugin context engines, Codex
app-server, native Responses compaction, micro-compaction and
background-review forks are not prepared. With the flag off no state is
created and compress() takes its existing path.
2026-10-25 22:55:11 -07:00
Teknium bbe207c593 refactor(config): ship the seam with the file backend only
The seam had a selector and reserved members that nothing in this PR
uses: HERMES_CONFIG_BACKEND accepted only `file` (every other value
exited), the .env override existed only to decide when to refuse,
UserLayer.locks/provenance and ConfigBackend.locked() were always empty,
and the supports_file_tooling()/require_file_tooling() checks at ten
file-copy sites were always true. All of it is for the remote backend
(#122509), so it lands there with the code that makes it real.

get_config_backend() now returns the file backend. The env var, its
docs row, the dotenv override, ConfigBackendUnavailable and the
file-tooling gates are gone, with the tests that covered them. The
explicit-file routing test now proves a non-config.yaml path never
consults the backend.

probe_config_readable() calls a probe_readable() protocol method
instead of an isinstance(FileBackend) check, and version_token()
documents how it treats an int-led version.
2026-10-25 18:54:09 -07:00
kshitijk4poor ab3e4affd6 fix(config): meet the code-health ratchet and keep pm's runtime tree off the YAML stack
CI on the salvage was red on the code-health ratchet (over-cap facades grew; two blind excepts)
and on the pm observer fixture, whose isolated source tree carries pm/ + hermes_cli/ but no utils.

- config_backend imports become module-level in the facades (it imports only stdlib); gateway,
  auth and telegram take config_exists through the hermes_cli.config import they already have.
- Pure helpers move to topical siblings, offsetting the growth: _csv_or_list_to_set ->
  gateway/run_common.py, the dotted config-path pair -> hermes_cli/backup_restore.py, the
  voice/timezone/web-backend checks -> hermes_cli/config_value_checks.py, _load_config_dict ->
  hermes_cli/profile_memory_config.py (both callers are memory-provider code).
- _load_config_dict catches YAMLError/OSError/UnicodeError instead of Exception.
- _load_config_cache_hit probes readability with config_backend.probe_config_readable (a 1-byte
  read through the backend's open) instead of a full parse plus a blind except.
- pm/plugins_state.read_home_selection checks config_exists before importing the YAML stack, so
  a home with no config never needs utils.
- developer-guide/config-backend.md describes this build (file backend only).

Same approach as @benbarclay's 74421be709 in #122509, which does not cherry-pick onto current main.
2026-10-25 18:54:09 -07:00
Ben Barclay 659de11cda docs(config): move the config backend seam's long form into developer-guide/config-backend.md
AGENTS.md + hermes_cli/AGENTS.md now has a 30,000-char chain cap
(scripts/ci/check_agents_md_size.py, new on main), and main's chain was
already at 29,982. The bullet becomes a one-line pointer.
2026-10-25 18:54:09 -07:00
Teknium d509ab10d7 fix(providers): lazy provider headers stay a real dict for plugin overrides
Review finding (P2, #144640): LazyHeaders was a read-only Mapping, so a
user provider override doing get_provider_profile('gmi').default_headers.copy()
(or |, update, json.dumps, ==) raised AttributeError and the provider
disappeared, in-process and under plugins.isolation: host.

LazyHeaders is now a dict subclass that stores every key up front and
resolves callable values (the release-carrying User-Agent) on first value
read. Every value-reading/mutating method resolves first; __iter__ is
overridden so dict(x)/{**x}/f(**x) leave CPython's raw-storage fast path;
json.dumps takes items() because the stored keys make it non-empty.
.copy()/|/copy/pickle yield plain resolved dicts. Importing provider plugins
still runs no git. The plugin-host Mapping materialization is gone: encode()
reads a dict subclass through items(), which resolves.
2026-10-25 17:50:59 -07:00
unsupportedpastels c289426df0 fix(gateway): record the resolved custom entry; map --source desktop client-side
Addresses maintainer review on #143593.

Refs #143547
2026-10-25 11:34:11 -04:00
unsupportedpastels 2c8e2e02af fix(gateway): resolve a bare custom provider to its named entry
custom is the billing class every named endpoint reports, not a route. Launch
policy, stored rows, synced runtimes and /status now carry custom:<name>,
matched by base URL and model in the owning profile. Also fixes the model-switch
crash on a profile-following session and accepts --source desktop as gui.
A client asking for a gateway-only label (cron, kanban, bot_room) is refused as
"reserved for the gateway", and a Kanban worker's descendants no longer inherit
its kanban label, so `hermes chat -q` from a task shell starts normally.

Rows MP-19, MP-20, MP-23, MP-25, MP-28; CLI-43.

Refs #143547
2026-10-25 11:34:11 -04:00
unsupportedpastels 342a55ebad fix(cli): /discard usage hint, parity note and connection test fakes
Addresses maintainer review on #143595.

Refs #143547
2026-10-25 11:30:24 -04:00
unsupportedpastels 4e35169b2b fix(cli): keep live reasoning after session switches and add /discard
The classic CLI re-sends show_reasoning after /new, /resume, /branch and a
gateway restart. A prompt behind an unknown admission now shows the lost-turn
banner instead of waiting silently, and /discard resolves it.

Rows CLI-50, GW-10.

Refs #143547
2026-10-25 11:30:24 -04:00
Teknium 2cd20e5a5e docs: hold_stream fails closed, keeps its hooks through a reload, hides commentary in history 2026-10-25 06:18:48 -07:00
Teknium 905e54d248 docs: hold_stream for transform_llm_output (hooks reference + plugin guide) 2026-10-25 06:18:48 -07:00
Teknium 850efff14f fix(vault): check the page origin before entering a backend's fill_context
After rebasing onto the shared origin guard (_bound_page_origin), browser_vault fill still entered the
backend's fill_context before the guard ran: a page on the wrong origin opened a plugin's manager
session or lock just to be refused. enter_code checked first, but then resolved the code from inside
the context without looking at the item again.

Both paths now run in this order, for built-in and plugin items alike: read the item's metadata, run
_bound_page_origin (plus the card confirmation for payments), and only then enter fill_context. Inside
the context a backend with its own fill_context has the item re-read, and the fill is refused unless
the frozen item still binds the checked origin. The origins checked and the secret resolved therefore
come from one snapshot, as the plugin guide promises. The built-in managers keep the default no-op
context and are not re-listed.

A plugin error while reading metadata for enter_code is now the same backend_unavailable refusal fill
gives. The write half of fill moves into _fill_bound_page, unchanged.

Tests: a plugin OTP on a foreign origin is refused with the backend never called (no context, no
resolve_otp, no prompt, nothing written; red on the pre-rebase head); an item re-bound inside the
context is refused for fill and enter_code before its secret is resolved (red for enter_code without
this change); the fill call order is now get_meta, fill_context, get_meta, resolve_password.
2026-10-25 05:32:43 -07:00
Teknium ded478f82f fix(vault): judge a login backend's name without running plugin code
The shared registrar read provider.name before the login-backend registry's static check, so a
name property (or a __getattribute__ hook) ran plugin code there and its exception text reached
the loader log.

The login-backend row now runs agent.vault_backend_registry.static_name first: it inspects the
class and instance dicts only, refuses attribute-access overrides and non-plain names, and logs a
fixed refusal reason.
2026-10-25 05:32:43 -07:00
Teknium 67ea21b293 fix(vault): copy plugin metadata inside the guarded call
PluginLoginBackend guarded the plugin method call but validated and later read the returned
VaultItemMeta outside it. A subclass or lazy accessor could raise there, and browser_vault list
copied that exception text into the model-facing result.

Validate and copy every returned field (metadata, passwords, codes, secret dicts) into
core-owned plain objects inside the same fixed-message guard.
2026-10-25 05:32:43 -07:00
Teknium 5c4c661ae6 fix(vault): automatic one-time codes run inside the backend's fill_context
browser_vault enter_code called resolve_otp outside the backend's fill_context. A plugin that opens
its manager session (or lock/snapshot) in that context filled the password, then failed to mint its
stored code, and Hermes silently fell back to asking the user.

Mint and write the code inside fill_context, as a password fill does; a backend without a code
(or failing to give one) still falls back to the user's prompt.
2026-10-25 05:32:43 -07:00
Teknium 69fb5c10ed docs(plugin-catalog): password-manager plugins are listed only from their vendor
Admission rule 18 (README and the mirrored developer-guide copy): a plugin that registers a
browser_vault login backend is listed only at tier verified, from the password-manager
vendor's own repository, or at tier official when Nous maintains it, and states that Hermes
enforces the same rule at load. browser-vault-plugin.md gains "Who can register a backend".
2026-10-25 05:32:43 -07:00
Teknium 18a3214ac1 docs(vault): state the isolation rules for browser-vault plugins
Rewrite the plugin-author page around what a password-manager plugin can and
cannot touch (own namespace, handle-only calls, no master-password prompt,
reserved names, off by default, in-process only, same origin-checked fill,
secret-free errors) and add it to the Plugins sidebar.
2026-10-25 05:32:43 -07:00
Jess Sullivan d4e19b9839 feat(tools): support scoped browser vault backend plugins
Huskycat-Receipt: v1;spec=42868e9ef8d7276ff7a143d63766f58e099ba891987567a17028fedc061cc95b;tiers=t0,t1;blobs=bc5861c5f4dd0ded777b118a2538a448bd1ce85a464e14650f08b9e4caad8dc6;pending=l8f617729aa7e7b60
2026-10-25 05:32:43 -07:00
emozilla 42bde77c68 fix(webapp): two gateways' Webapps on one hostname keep their own sign-ins
Cookies are not scoped to a port, and only the sign-in binding cookie was named for the gateway. The
login itself (hermes_session_at, _rt, _provider) kept main's names, so when a browser signed in to a
second gateway on the same hostname (another user's, or a standalone profile's on 9121) with its own
sign-in provider or provider secret, those cookies replaced the first gateway's login and the first
Webapp answered session_expired. Signing out of one in the browser cleared the other's login too.

On the Webapp's listener every sign-in cookie it sets is now named for the gateway, with the same
suffix as the binding: the access and refresh token, the provider hint, the OAuth state cookie and
the auto sign-in marker. The listener reads and clears only those names, so cookies with main's names
(the standalone dashboard's on the same hostname, or this branch's earlier builds) are left alone
and such a browser signs in again. The standalone dashboard keeps main's names. Native Desktop's
check for a cookie login on a remote gateway accepts the per-gateway names.
2026-10-25 03:22:37 -07:00
emozilla 4e54af5d8d test(webapp): a browser login's access token opens none of the Webapp's token sockets
@teknium1's 56be810f764 makes the WebSocket upgrade's ?token= leg ask
webapp_sign_in.token_works_alone first, as the bearer header does. That commit tests /api/ws. This
one tests every socket on the Webapp's listener that takes a provider token as ?token= (/api/ws,
/api/events, /api/pub, /api/pty and both voice streams): a browser login's access token, copied out
of its cookie, opens none of them before a sign-out or after it. It also tests that a token the
listener gave a native app's sign-in opens a socket that stays open across a sign-out, as the native
app stays signed in.

token_works_alone's docstring, the module docstring, the developer guide and the Desktop guide now
name a socket's ?token= among the places the gateway refuses such a token on its own.
2026-10-25 03:22:36 -07:00
emozilla 0306614758 fix(webapp): keep the sign-ins of two gateways' Webapps on one hostname apart
Every gated Webapp bound its browser logins with a cookie named `hermes_webapp_signin`. Cookies are
not scoped to a port, so a browser that used two gateways' Webapps on one hostname (two profiles'
gateways on different ports, say) kept one such cookie for both: signing in to the second replaced
the first one's binding, and the first then answered as signed out. Where both gateways accept the
same account tokens (one sign-in provider setup), this cookie was the only thing they fought over.

The cookie is now named `hermes_webapp_signin_<gateway>`, with a suffix from a digest of the gateway's
home. It stays the same across restarts and differs for every gateway on one machine, so each
Webapp keeps its own binding, and signing one gateway's browsers out leaves the other's alone.
2026-10-25 03:22:31 -07:00
emozilla 9538b1b040 fix(webapp): end the account tokens of a signed-out browser login on the Webapp too
A browser that signs in to the gated Webapp keeps the sign-in provider's access and refresh token
in its cookies. Only the cookie path checked the login's binding to the browser credential, so
after `hermes webapp --sign-out` those tokens still worked when presented on their own: the access
token as `Authorization: Bearer` (enough to mint a host-terminal ticket) and the refresh token on
`/auth/native/refresh`, where the bundled password provider hands out a new 30-day refresh token
every time. Refusing a recorded list of those tokens would not hold: the password provider decodes
base64 leniently, so another spelling of the same token, such as the quoted value the cookie
stores, verifies too.

On the gateway's Webapp behind the sign-in gate, a provider token now works on its own only when
the listener gave it to a native app's RFC 8252 sign-in (`/auth/native/token`, then each
`/auth/native/refresh`). The listener keeps a digest of each such token until it expires, in
`webapp/native-sign-in-tokens.json` in the gateway home, so native apps stay signed in across a
sign-out and a restart. A browser login's tokens work only as that login's cookies, which
sign-out, or a credential replaced while no gateway ran, already ends. The standalone dashboard
and the gateway's private listener keep main's behaviour. `hermes webapp --sign-out` and the docs
now say that those tokens end and that native apps stay signed in.
2026-10-25 03:22:31 -07:00
emozilla ac64150960 docs(webapp): describe sign-out behind the sign-in gate and where launch links work
The Webapp docs said `hermes webapp --sign-out` signs out the tabs a launch link signed in. They
now say it also signs out every browser that signed in with a dashboard account behind the
sign-in gate, closes Bot Desktop viewers with the rest of a tab's connections, reports how many
connections it closed, waits for a gateway that is still starting, and replaces the stored
credential itself only when no gateway runs. The private-link fallback is documented for the BSDs
as well as macOS, and the developer guide records how a Webapp login is bound to the browser
session token (the hermes_webapp_signin cookie).
2026-10-25 03:22:25 -07:00
emozilla fbdf0b0f22 docs(webapp): say what turns the Webapp's sign-in on and what the gateway does around it
The docs said only a 0.0.0.0 or :: host turns on the Webapp's sign-in gate. A public URL does too,
on a loopback host, and `hermes webapp` then opens the Webapp's address instead of a one-use link.
The configuration reference, the Desktop guide, the `hermes webapp` reference and the gateway
sessions guide now name both triggers (webapp.public_url, not dashboard.public_url), say that the
listener native clients use never asks for sign-in, and say that a taken Webapp port replaces the
browser sign-in so open tabs need `hermes webapp` again.
2026-10-25 03:22:24 -07:00
emozilla 7979fb2ce3 docs(webapp): document the gateway-served Webapp
Configuration, the hermes webapp command, the Desktop guide's browser
section, and the operator credential in the gateway session notes.
2026-10-25 03:22:17 -07:00
Teknium 4ea8d23cc9 fix(agent): side-question review fixes (unattended turns, profile-bound leases, billing, attribution)
- No side_question_token for cron runs (platform="cron") or delegated subagent turns
  (_delegate_depth > 0): nobody reads the answer, so it is pure unattended spend.
- A lease is bound to the profile home it was issued in; a plugin context of another profile
  gets None and leaves the lease for its owner.
- Usage bills to the session the token was issued for (capture.session_id), not the parent's
  id at release time, which can rotate between issue and redeem.
- Plugin side questions report as aux_task="plugin_side_question" with the asking plugin_id in
  pre/post_auxiliary_call, and record usage as plugin_side_question:<plugin_id>; /btw keeps
  side_question.
- Drop the new legacy key llm.allow_side_question: the capability is declare + consent only.
- The timeout starts before the fork build, so runtime resolution counts against it.
2026-10-24 19:56:21 -07:00
Teknium 3f7af72898 fix(agent): side-question review findings (runtime pin, extra_body tools, hook bracket, lease cap)
- Refuse when the parent's model/provider/base_url/api_mode/reasoning
  changed since the turn was captured: a voice-chat turn routed to
  auxiliary.voice_chat restores the main runtime right after
  post_llm_call, and the captured prompt must not go out on another model.
- has_server_side_tools also reads extra_body.tools: request_overrides
  land there and the SDK merge sends them over the top-level tools.
- One pre/post_auxiliary_call bracket around the whole attempt, so a
  reply that fails after the provider returned (no choices) still closes
  the observer's request with its error; a cancelled call reports an
  InterruptedError instead of an empty success.
- Drop the 32-lease global eviction: it revoked live sessions' tokens on
  a busy gateway. The table already holds at most one lease per live
  agent (new turn, expiry and a dead parent all purge).
2026-10-24 19:56:21 -07:00
Teknium 4047c3ab72 fix(agent): ctx.side_question reports through pre/post_auxiliary_call
The side request fired the turn-scoped pre_api_request/post_api_request
after its turn had already closed, so trace-per-turn observers (and the
relay metrics keyed on them) counted a plugin's side call as a model call
of a finished turn. Every other side call (titling, compression, MoA,
vision, ...) reports through pre/post_auxiliary_call instead.

- turn_api_request: build_request_payload() is the payload build through
  the LLM request middleware with no hook; build_api_request() wraps it
  with pre_api_request, the debug dump and the MoA handshake, unchanged
  for the turn loop.
- auxiliary_hooks: start_aux_call_hooks()/finish_aux_call_hooks() expose
  the pre/post pair for callers that drive the provider call themselves;
  post accepts an already-normalized (message, finish_reason, usage) view
  for a transport-native reply.
- side_question_request: build_request_payload + aux hooks with
  aux_task="side_question"; a request refused for server-side tools now
  fires nothing.

Test: a real plugin observing all four events sees pre/post_api_request
for the turn only and pre/post_auxiliary_call (with usage) for the side
request (red before).
2026-10-24 19:56:21 -07:00
Cleo a8db83446f feat(plugins): ctx.side_question asks the session's own agent after a turn
post_llm_call now carries a single-use side_question_token when a loaded
plugin holds the new llm.side_question capability. ctx.side_question(token,
prompt) redeems it: ONE text-only request on a cache-parity fork through
the turn loop's own request phases (assemble_api_request, build_api_request,
perform_api_call), never the tool loop. The parent's system prompt, tools[],
reasoning config and max_tokens are replayed verbatim, so the request reads
the warm prompt cache. Tool calls in the reply are dropped, nothing is
persisted, and usage lands on the session as side_question.

Lease rules: one attempt per turn shared by every plugin (consumed even on
failure), 60 s, invalid once the parent starts its next turn, prompt capped
at 4096 chars, timeout capped at 60 s. The lease holds its parent weakly and
runs under the turn's captured contextvars (profile and secret scope).
Refused with None: MoA, agent-as-provider runtimes, requests that carry
provider-executed tools.

has_capability moves with side_question into a PluginContext mixin
(hermes_cli/plugins_capability_ctx.py) so hermes_cli/plugins.py shrinks.

Fixes #143788
2026-10-24 19:56:21 -07:00
Teknium e3f1dced6b docs: plugin workspaces solve only for the installing platform 2026-10-24 19:27:03 -07:00
Teknium 8373d67547 fix(compression): hold only the floor when the trigger cannot fit a summary
At or below _MIN_SUMMARY_TOKENS (2K) the summary alone fills the trigger, so
no tail size leaves room under it; clamping the 2.5%-of-window term there only
drops recency context (a 1K test trigger on a 256K window merged the handoff
into a tail row). Keep #144177's floor-only clamp in that regime. The lean
budget moves to context_compressor_tail.lean_tail_budget.
2026-10-24 18:45:02 -07:00
Teknium a56af5699f fix(compression): the trigger cap covers the whole lean tail budget
#144177 capped only the 10K floor at half a configured trigger; the
2.5%-of-window term still kept 25K at 1M, so the tail stayed at 123%/216%/86%
of a 12K/12K/30K trigger on 400K/1M windows and the refusal loop remained.
Clamp the final lean budget instead. Default-config budgets are unchanged on
every window from 16K to 1M (threshold_percent 0.5 already exceeds 5%).

Follow-up to #144177 (independent review D3).
2026-10-24 18:45:02 -07:00
Teknium c422d69189 fix(prompt): one-shot runs drop steering, clarify and delegate live-control; skill-safety rule moves to compaction
Maintainer-approved prompt-footprint trims (measured on the Nous endpoint, anthropic/claude-opus-5.5):

All sessions
- The [SKILL_PRUNED] "Skill Safety Rule" (126 tok) left the system prompt: markers only exist after a
  compaction, so the compaction handoff now carries the rule whenever a marker survives in the
  compacted transcript (context_compressor_tail.with_skill_safety_rule, applied in
  _assemble_compressed for LLM and fallback summaries alike). The "## Pruned Skills" block drops
  its own parenthetical, which the rule now covers. `hermes sessions repair-prompts` keyed its
  "full build" detector on the rule's heading; it now keys on SKILLS_GUIDANCE (present in prompts
  built before and after this change).
- The CLI/TUI cron "LOCAL-ONLY" caveat (154 tok) moved from the platform hints into
  cronjob_manage's deliver description (a deferred tool: paid only when loaded).
- The Hermes help pointer no longer restates identity ("You run on Hermes Agent (by Nous
  Research)") after SOUL.md's "You are Hermes Agent, built by Nous Research."; it now opens "For
  help with Hermes Agent itself — ...", which still tells a custom persona what it runs on (22 tok).

One-shot sessions only (-q/--oneshot/-z, and gateway-owned `oneshot` policy sessions)
- No "## Mid-turn user steering" note (253 tok): nobody can steer a one-shot.
- No clarify tool (590 tok): ONESHOT_DISABLED_TOOLSETS is folded into session_disabled_toolsets
  (oneshot=...), so the agent tool build, MCP refresh, the tool_search bridge, delegate children and
  the gateway's session inventory / tool listings all drop it together. skill_manage stays.
- delegate_task without live control (425 tok): action/subagent_id/message and the
  background-delivery paragraph are removed (children are joined inside the call), applied via
  toolsets.agent_tool_surface at load and MCP refresh. The one-shot fact is frozen on the agent at
  its first tool build so refreshes stay byte-stable.

Interactive tool schemas are byte-identical to main.
2026-10-24 17:31:52 -07:00
Teknium 7c4e7e5517 fix(desktop): desktop-only repos cannot pose as a package; media paragraphs nest cleanly
The git installer copied a desktop-only repo verbatim, so a shipped
.hermes-package.json named another package's agent half as its own (pairing
it and letting it render that package's tool media). The installer now drops
it; only the installer and the reconcile pass write the marker.

A paragraph holding a file link nested in emphasis, or a bare file link, now
becomes the block wrapper too (no validateDOMNesting warning), keeps its props
and the paragraph's bidi rules. Docs cover the refined ownership rules.
2026-10-24 06:14:06 -07:00
Teknium 4fb80ec2ac fix(desktop): exact tool ownership per chat owner; whole-path producer match
Review fixes (Greptile on #143980):

- Ownership is exact: `plugins.manage list` reports each package's tools by
  name, including the tools its MCP servers actually registered, instead of
  `mcp__<server>__` prefixes (sanitized names can collide and a clamped long
  name loses its prefix). A name two packages list belongs to neither;
  Desktop's longest-prefix guess is gone and `tool_prefixes` is dropped.
- Ownership follows the chat: the list is fetched from the session's own
  owner route with its profile (`knownOwnerForSession` +
  `requestForSessionProfile`) and cached per owner, not from the window's
  active gateway.
- Stale answers never restore removed ownership: each fetch carries a request
  id and only the newest request for an owner may write; a plugin-list change
  clears the cache and orphans in-flight answers.
- A failed fetch retries with a bounded backoff (2 s doubling to 60 s)
  instead of latching the error.
- The producer match is a whole-path match (`/out/a.glb` never matches inside
  `/out/a.glb.bak` or `/x/out/a.glb`).
2026-10-24 06:14:06 -07:00
Teknium 6dc1c398e2 feat(desktop): plugins render the media their own tools produce
A Desktop plugin can now register how Desktop shows a MEDIA: attachment
produced by one of ITS OWN tools (TOOL_MEDIA_RENDERER_AREA,
'transcript.toolMedia'). Ownership is provenance, never file type: the
transcript finds the tool call in that turn whose result produced the path
(earliest call wins, so a later `ls` only mentions it), and the plugin's
component replaces the built-in player only when that plugin's package
registered the tool.

Why: the elevenlabs-sound-effects and meshy plugins each needed a custom
card for their files and had to make the model emit a ::directive (plus a
backend handshake) to get one, which leaves non-Desktop users a raw line.
With this seam the tool keeps returning a plain MEDIA: tag every surface
understands and Desktop alone upgrades it.

- Backend: `plugins.manage list` rows carry `tools` (native tools the
  loaded plugin registered, minus built-in names and names two plugins
  list) and `tool_prefixes` (`mcp__<server>__` for each MCP server the
  loader attributed to a portable package). Contract + generated TS.
- Desktop: lib/tool-media-renderers.ts (contract, producer lookup,
  ownership resolution), store/tool-media-owners.ts (lazy per-route
  ownership list), components/assistant-ui/tool-media-slot.tsx wired into
  MarkdownLink's MEDIA and bare-path branches. Claims on core or other
  plugins' tools, or from a desktop plugin with no agent half, are refused
  with a console warning. A throwing renderer, a disabled plugin or an older
  backend fall back to the built-in player.
- Props: file {path, name, kind, mime, resolveUrl()} (stream route for
  audio/video, works on remote backends), toolName, toolCallId, args,
  result, sessionId, fallback.
- Docs: desktop-plugin-sdk.md section with a worked example; skill ref.
2026-10-24 06:14:06 -07:00
Teknium 7ae117eb4c fix(plugins): name portable plugin skills after the plugin, not a hashed namespace
A portable plugin's skills registered as agent-plugin-<slug>-<sha8>:<skill>
(agent-plugin-blender-5a80e4ba:blender) because one manifest field named both
its plugin-data directory and its skill namespace. The hash only matters on
disk. Skills now take the plugin's own name (blender:blender), the form every
native plugin skill uses; a clash with an already-registered qualified name is
skipped with a warning naming the owner, as before. The data directory keeps
its hashed name, so no plugin data moves, and skill_view still resolves names
saved in the old form.
2026-10-24 04:01:10 -07:00