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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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>
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.
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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".
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.
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.
@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.
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.
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.
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).
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.
- 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.
- 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).
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).
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
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.
#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).
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.
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.
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`).
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.
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.