Commit Graph

6506 Commits

Author SHA1 Message Date
kshitijk4poor
3fc3caeb98 fix(relay): native Anthropic stream spans keep their input and cache token counts
On the native Anthropic stream, every Relay LLM span exported prompt, cache-read and
cache-write token counts as missing; only output tokens survived. The SDK's
MessageDeltaUsage serializes its unset fields (input_tokens, cache_read_input_tokens,
cache_creation_input_tokens) as null, and AnthropicStreamAccumulator's message_delta merge
let those nulls overwrite the real values captured from message_start. The Anthropic SDK's
own accumulator merges only non-null delta fields; ours now does the same. Hermes' own
session accounting was unaffected (it reads the SDK snapshot); the loss was the Relay
trace, which is what cache-reuse analysis of exported traces runs on.
2026-10-07 00:18:10 +05:30
Austin Pickett
ccff4df98c fix(skills): trust-scope inline-shell auto-exec to non-community hub skills
Inline `!`cmd`` expansion in SKILL.md bodies ran for every skill whenever the
global skills.inline_shell flag was on — a community hub skill and a builtin
were expanded identically. The hub's install gate (INSTALL_POLICY) blocks a
community install on the caution/dangerous verdicts the inline-shell DSL scans
as, but --force or a pre-scanner install puts that skill on disk anyway, and
the next view/slash/preload would auto-execute it on the host.

Gate expansion at the single preprocess_skill_content choke point (all three
surfaces route through it): community-trust hub lock entries never expand;
trusted/builtin hub entries and anything without a hub lock entry (bundled-
synced, user-created, project/external) keep the flag's contract. Lock read
failures skip the gate, like every other provenance consumer.

Fixes #63307
2026-10-06 11:20:43 -04:00
victor-kyriazakos
5da149b45f fix(bedrock): route GPT-6 family and prefixed OpenAI inference profiles, size their 1M window (#133573)
* fix(bedrock): route GPT-6 family and prefixed OpenAI inference profiles, size their 1M window

An enterprise deployment on provider bedrock with model us.openai.gpt-6.1-sol
compressed at 96,000 tokens with a 400K compression cap configured. Three gaps,
all on origin/main c53536929e:

1. agent/bedrock_adapter.py:77-79 BEDROCK_OPENAI_RESPONSES_MODEL_IDS held the
   GPT-5.5 and GPT-5.6 IDs only (unchanged since 16476fad10). AWS now publishes
   cards for GPT-6 Astra, GPT-6 Sol, GPT-6 Luna and GPT-6.1 Sol.
2. agent/bedrock_adapter.py:165-167 is_openai_bedrock_model was an exact set
   match, so us.openai.gpt-5.6-sol returned False while openai.gpt-5.6-sol
   returned True. Every cross-Region inference profile of a Bedrock OpenAI model
   was misclassified.
3. agent/bedrock_adapter.py:1169-1195 BEDROCK_CONTEXT_LENGTHS had no GPT-6
   rows, so get_bedrock_context_length fell to the 128,000 default silently
   (the fallback WARNING fired only for application-inference-profile ARNs).
   agent/context_compressor.py:1181 applies the 75% small-window floor, which
   is exactly the observed 96,000.

Per the AWS model cards (model-card-openai-gpt-6-1-sol.html and siblings):
the bare openai.* ID is served in-Region by bedrock-mantle (Responses, Chat
Completions, no geo or global IDs), and the us./global. profile IDs are served
by bedrock-runtime where Converse is listed as supported. GPT-6.1 Sol states a
1M context window with 131,072 max output tokens, the other cards state
1,050,000. 272K on these cards is the long-context pricing tier boundary, not
the window.

Changes
- parse_bedrock_openai_model_id(model_id) -> (bare_id, profile_prefix) | None.
  is_openai_bedrock_model is True for every endpoint form of an allowlisted ID,
  GPT-OSS stays False. New bedrock_openai_uses_mantle is True only for the bare
  in-Region ID. Both callers (hermes_cli/runtime_provider_backends.py primary,
  agent/auxiliary_client.py auxiliary) route Mantle on that predicate and keep
  prefixed IDs on Converse via bedrock-runtime, the path AWS documents for them.
  The Claude profile-prefix regex now derives from the same prefix tuple.
- GPT-6 generation added to the allowlist, the static picker catalog and the
  context table. OpenAI rows sized from the cards (6.1 Sol 1,000,000, others
  1,050,000). The table/metadata pairing test covers the new rows.
- get_bedrock_context_length logs one WARNING per model ID per process when
  the static default is used for any ID, naming the model, the value, that it is
  a fallback, and the model.context_length override.
- website/docs/guides/aws-bedrock.md: route table, model table and the
  cross-Region note describe both endpoint forms.

The empty compression summary the deployment also reported is not addressed
here. Converse is a valid path for the prefixed ID, so routing does not explain
it, and no request trace exists yet.

* chore(bedrock): keep auxiliary_client.py within its code-health line cap

Rewrap the _build_bedrock_client docstring back to two lines so the file does not grow past its value on main (code health ratchet, FILE_LINES).

---------

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
2026-10-06 08:13:51 -04:00
Teknium
3861f1da62 fix(web): pick own key or Nous Tool Gateway per web capability in Desktop
Desktop's web picker had no way to send one capability through the Nous
Tool Gateway while the other used your own key. "Use for Search/Extract"
on the Nous Subscription row wrote the row's vendor string (firecrawl)
into web.<cap>_backend, which the runtime reads as BYOK Firecrawl: the
gateway was never used, and the managed and BYOK rows lit up together.

- The managed per-capability pick now stores `nous`; search/extract
  dispatch, the registry and the Firecrawl client resolve the route per
  capability (search: managed Perplexity, extract: managed Firecrawl).
- A whole-toolset Nous pick clears stale per-capability pins (they
  outranked the new selection).
- /api/tools/toolsets/web/config reports search_via_nous /
  extract_via_nous and each row's managed_nous_feature; the panel lights
  the right row, labels the route, and refetches after a pick instead of
  mirroring the vendor string.

Fixes #100513. Builds on the approach in #100539 by @liuhao1024 and the
self-healing pin clear from #122676 by @praggybuilds.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Murphy (praggybuilds) <praggybuilds@users.noreply.github.com>
2026-10-06 02:53:56 -07:00
kshitijk4poor
90194c64ff refactor(cache): one scaffold-split builder and one prior-decoration strip pass
Simplify pass over the skill-scaffold split fix:

- _scaffold_parts builds the [scaffold, tail] parts for both the marked split
  (_apply_cache_marker) and the unmarked one (_split_unmarked_scaffolds). The
  stripper only recognises the unmarked split while both stay byte-identical,
  so they now come from one place.
- _strip_prior_decoration owns the "strip every list-content message before
  re-planning" gate. build_prompt_cache_plan no longer strips and then hands
  the result to apply_anthropic_cache_control, which stripped it again.
- strip_anthropic_cache_control checks the registry only for plain-text
  two-part user messages, on the text it already joins for the flatten. The
  old check also ran for non-text parts, where a lookup miss compared
  None == None.
- apply_anthropic_cache_control docstring: drop the duplicated "Idempotent"
  paragraph and the claim that only marked messages pay the copy cost (false
  since the gate widened).
- Tests: cover the direct native tool-cache layout, which plans without
  apply_anthropic_cache_control (red with its _split_unmarked_scaffolds call
  removed). The re-plan test no longer depends on the marker window size.
2026-10-06 14:01:25 +05:30
Teknium
a0e066d614 review-fix(cache): re-planning an unmarked skill split marks the scaffold, not the tail
apply_anthropic_cache_control only flattened rows that still carried a marker. After
#133715 a skill message outside the marker window goes out as an UNMARKED
[scaffold, tail] split, so a second pass over the planner's own output (MoA reference
trim, failover re-decoration) saw a plain two-part list and put the new breakpoint on
the volatile tail. Use the same list-shape gate build_prompt_cache_plan already has,
so the re-plan equals a fresh plan from the canonical string.

Test is red on the PR head, green with the fix.
2026-10-06 14:01:25 +05:30
kshitijk4poor
e56c8d6ddb fix(cache): skill turns no longer re-write their whole scaffold after the first tool round
A skill-invoked turn's user message is sent as [scaffold, tail] text parts so the scaffold can
carry a cache breakpoint (#81867). The split only happened inside _apply_cache_marker, i.e. only
while the message held one of the last-N markers. After the first tool round pushes it out of
the window, the same message went out as one plain string — different content to the provider,
so request 3 re-wrote the entire skill body. agent.log shows it on call 2 of skill-invoked
sessions: 6 of 8 sessions re-wrote ~20k tokens there, versus 1 of 147 plain sessions.

Every registered scaffold message is now sent split, marked or not, so its content is stable for
the whole session. The failover stripper flattens the unmarked split too (registry lookup, since
an unmarked two-part user message is otherwise indistinguishable from an organic one).

Introduced by 214f2b82db (perf(cache): split skill turns at a builder-declared stable/volatile
boundary, joaomarcos, 2026-08-08, landed via #82423).

Tests: one round-trip cell outside the marker window pins identical parts (red on
origin/main) and the stripper flattening the unmarked split (red with the
stripper clause removed).
2026-10-06 14:01:25 +05:30
Teknium
f9798bcf9b fix(tts): validate a plugin's stream rate once; keep nous off plugin routing
Independent-review findings on the plugin PCM streaming path:

- stream_sample_rate was checked, then re-read and int()-ed in the
  adapter: NaN/inf raised out of resolve_streaming_provider (the speaker
  then played nothing) and 0.5 became sample_rate=0. Validate once
  (finite, >= 1 Hz) and hand that int to _PluginPCMStreamer.
- `nous` is an alias of the openai path in _get_provider but not a
  reserved name, so a pinned `tts.streaming.provider: nous` could stream
  a plugin registered as `nous` while the sync path speaks OpenAI.
- Document that stream() must be thread-safe: the speaker prefetches up
  to 3 sentences, unlike the serialized synthesize() path.
2026-10-06 00:52:41 -07:00
Teknium
10f5530967 fix(tts): plugin PCM streamers keep the sync dispatcher's voice and routing
Follow-up to the salvaged plugin-streaming commit. The adapter read
voice/model from the plugin's own tts.<name> section, so a streamed reply
spoke the plugin default while synthesize() got tts.voice: the voice
changed with the surface. It also skipped the sync dispatcher's routing:
a same-named `type: command` provider (which wins for synthesize) lost
to the plugin on the streaming path, and an is_available() == False
plugin was streamed anyway.

- tools/tts_tool_plugins: one routing guard (_plugin_route_key) and one
  voice/model/speed builder (_plugin_voice_kwargs), shared by
  synthesize() dispatch and the new _plugin_pcm_streaming_provider
  (streams_pcm + positive stream_sample_rate + is_available(), read at
  resolve time)
- tools/tts_streaming: _PluginPCMStreamer stays a thin adapter;
  stream_channels/stream_sample_width dropped (the contract is int16
  mono, which every sink assumes)
- agent/tts_provider: streams_pcm / stream_sample_rate declared on the
  ABC with the contract
- tests: two invariant tests next to the plugin dispatch tests (the
  streaming test module skips without numpy); docs for plugin authors
2026-10-06 00:52:41 -07:00
kshitijk4poor
3ffd44e4d3 fix(agent): an init-time fallback carries its own identity into requested_provider
The init-time fallback in _routed_client_kwargs switched agent.provider to the
fallback entry but left requested_provider naming the primary. The
answer_in_reasoning gate now scopes trust by requested_provider, so a primary
that fell back at construction to a same-base_url sibling inherited the
primary's opt-in. The runtime fallback, /model switch and MoA paths already set
both fields; this one now does too.
2026-10-06 13:01:39 +05:30
kshitijk4poor
cff9305fe6 refactor(agent): drop redundant str() wrapping around custom_provider_slug args
Gate C11 r4 quality: custom_provider_slug already coerces with
str(value or "").strip(), so the caller-side wrapping was noise.
No behaviour change.
2026-10-06 13:01:39 +05:30
kshitijk4poor
a1bccf3344 fix(agent): accept only a real bool provider-level answer_in_reasoning
Gate C11 r4 quality: the `flags &= {True, False}` set filter let YAML
`1` count as an opt-in because 1 == True in a set. Keep only bool values
so a stray 1/0 never promotes private reasoning. New parametrize row
(acme-int, value 1) is red without this.
2026-10-06 13:01:39 +05:30
kshitijk4poor
c4895a27a9 fix(agent): match answer_in_reasoning entries against requested_provider too
Gate C11 r4 2c: startup, gateway and TUI build the agent with
provider="custom" and the named identity in requested_provider; only a
/model switch sets provider to the custom:<slug>. Matching entries against
agent.provider alone left the live-entry set empty at startup, so an
unflagged sibling on a shared endpoint still inherited the opt-in and a
sibling's false cancelled the provider that opted in. Collect the
custom:<slug> identity from both fields. The acme-plain parametrize row now
uses the startup shape (red before this change) and one opted-in row pins
the positive startup path.
2026-10-06 13:01:39 +05:30
kshitijk4poor
246a1e2eec refactor(agent): drop the unwritten runtime_capabilities answer_in_reasoning branch
Gate finding (C11 r3 quality, Low): 5769ff4aa9 removed the only writer of
runtime_capabilities["answer_in_reasoning"] (the agent_init seed); every
remaining writer stores resolve_native_compaction_capabilities(), which
yields only native_compaction. The reader was an unconfigurable second
trust source that could override the live config read, reachable only
from a test seam. The test now opts in the way users do, through a
custom_providers capabilities block on its base_url.

Also import _entries_for_route from hermes_cli.config like its neighbour.
2026-10-06 13:01:39 +05:30
kshitijk4poor
14701564b4 fix(agent): scope the provider-level answer_in_reasoning opt-in to the live entry
Gate finding (C11 r3, Low): the provider-level loop returned the first
entry on the base_url carrying an answer_in_reasoning bool, whatever
provider was live. Two providers: entries on one endpoint (LiteLLM/vLLM
router) leaked one's opt-in to the other, and an earlier sibling's true
overrode the live entry's explicit false (probe: vllm-nemo true + vllm-ds
false on one URL, live custom:vllm-ds -> True).

Match the live custom:<slug> via custom_provider_slug, the helper that
derives that identity from an entry. With no slug match (bare 'custom'),
trust only when every flagged sibling on the endpoint opts in.
2026-10-06 13:01:39 +05:30
kshitijk4poor
0044d3a7d0 refactor(agent): drop the constructor answer_in_reasoning seed now the gate reads the route
52e8ecd495 merged the constructor `capabilities=` answer_in_reasoning bool
into runtime_capabilities at startup so a provider-level opt-in reached
the promotion gate. The gate now re-reads the provider-level
`capabilities:` block on the live route (previous commit), which covers
CLI/TUI and /model switches the seed never did, so the seed is redundant.
Forward removal, not a revert: 52e8ecd495's test coverage stays, but the
fixture now opts in through the custom_providers entry-level
`capabilities` block instead of the constructor (fold2 finding 1).
2026-10-06 13:01:39 +05:30
kshitijk4poor
1ddcb56a64 fix(agent): re-read the provider-level answer_in_reasoning opt-in on the live route
The provider-level `capabilities: {answer_in_reasoning: true}` block only
reached the promotion gate through the constructor `capabilities=` arg,
which only the gateway passes. CLI and TUI never pass it, and every
/model switch rebuilds runtime_capabilities without it, so the documented
provider-level opt-in was silently ignored on CLI/TUI and lost after
/model in the gateway (fold2 finding 1, 2c review).

Read the entry-level block live, like the per-model key: when no
per-model capability is set, scan the custom_providers entries serving
the live base_url. It now holds on any model of that provider, is never
inherited by a fallback on another base_url, and OpenRouter stays
untrusted. The trusted-route test opts in through the entry-level block
with no constructor capabilities and gains /model-switch and fallback
rows (the first two are red without this change).
2026-10-06 13:01:39 +05:30
kshitijk4poor
d1024897ac fix(agent): honour the constructor answer_in_reasoning opt-in at the promotion gate
The live-trust refactor dropped the writer of
runtime_capabilities["answer_in_reasoning"] but kept its reader, so an
explicit opt-in passed as AIAgent(capabilities={"answer_in_reasoning": True})
(what _lift_model_capabilities builds from a provider-level `capabilities:`
block and gateway/run.py passes through) never reached the gate and private
reasoning-only stops on that route were retried instead of promoted.

Seed it into runtime_capabilities at startup, merged into the existing
expression so agent_init.py does not grow past its size cap. The opt-in is
scoped to the primary route: a fallback rebuild drops it and the primary
restore brings it back from the snapshot. The fixture now opts in through
the constructor and the trusted-route test gains a constructor-opt-in row
(red without this change).
2026-10-06 13:01:39 +05:30
kshitijk4poor
2f0efe8f66 refactor(agent): derive reasoning-promotion trust live in agent/reasoning_promotion
The code-health ratchet blocks growth of agent_runtime_helpers.py
(3909), agent_init.py (2527), chat_completion_helpers.py (4122) and
finish_text_response (309 lines); the gate added 60+ lines to those
facades.

Move answer_in_reasoning_capability into a topical sibling and evaluate
it from the agent's live route at the promotion site instead of caching
the decision in runtime_capabilities. That removes the plumbing at
startup, primary snapshot/restore, switch_model and fallback activation:
a live check cannot go stale across a route change, so the whole
sibling-site class is covered by construction. The unused constructor
`capabilities={"answer_in_reasoning": ...}` hook goes too (no caller
sets it); an explicit runtime_capabilities bool and the
custom_providers per-model opt-in still work.
2026-10-06 13:01:39 +05:30
kshitijk4poor
806b1e99c9 refactor(agent): drop defensive capability try/except and dead reasoning_only copy
get_custom_provider_model_capability already returns None when the
config cannot be read, so the try/except around it in
answer_in_reasoning_capability only hid real bugs.

_terminal_empty no longer returns site_copy("reasoning_only") with a
reasoning preview, and nothing else references that entry, so remove it
instead of leaving dead copy behind.
2026-10-06 13:01:39 +05:30
Mohamad Kanso
71c1669404 fix(agent): gate reasoning-only answer promotion by route
A clean-stop response with empty content and a reasoning payload was
promoted to the visible answer on every route. A field name is not
provenance: OpenRouter, DeepSeek and other adapters put private
chain-of-thought in `reasoning`, so users saw the model's private
thoughts as the reply (#111761, #114082). Exhausted recovery also echoed
a 500-char reasoning preview.

Promotion now requires a trusted route: an explicit
`answer_in_reasoning` runtime capability, a custom_providers per-model
opt-in, or the local Nemotron-3.5-Lightning parser route from #109205.
The decision lives in runtime_capabilities and is re-derived at startup,
fallback activation, primary restore and model switch. Exhausted
recovery returns the empty sentinel without a reasoning preview.

Rebuilt by hand on current main (keeps the Anthropic signed-thinking
skip and the _refund_api_call import added since the PR's base).

Salvaged from #112833 (a339569d77, 15a19b2fc0, 1d240e7b24).
2026-10-06 13:01:39 +05:30
kshitijk4poor
a94c1065be docs(ssl): note SSL_CERT_FILE sits on top of the platform store
The module docstring still said only explicit per-provider config sits
above the platform store; platform_ssl_context() now honours an exported
SSL_CERT_FILE bundle in addition to it.

Fold3 finding 3 (quality Low).
2026-10-06 12:25:01 +05:30
kshitijk4poor
72fc5e4476 refactor(ssl): collapse the duplicated union arm in _shared_context
The SSL_CERT_FILE (union) arm repeated the platform arm line for line
plus a load_verify_locations call. Build the context from one of two
classes (stdlib for a replacing provider bundle, injected for everything
else) and load the bundle once when there is one.

Equivalence probed on 44a807c2c7 vs this head for (path, False),
(path, True), (None, False): identical type, verify_mode, check_hostname
and cert_store_stats().

Fold3 finding 1 (reuse/quality Low).
2026-10-06 12:25:01 +05:30
kshitijk4poor
f95f54314a fix(ssl): trust SSL_CERT_FILE on top of the platform store, not instead of it
Gate finding (C02 fold2 #1, High regression): platform_ssl_context()
built the SSL_CERT_FILE context from the un-injected stdlib class with
only that bundle loaded. Before this stack the Telegram clients used
httpx verify=True, i.e. ssl.create_default_context(cafile=SSL_CERT_FILE)
on the truststore-injected class, which trusts the bundle PLUS the OS
store. An operator exporting a private-CA-only SSL_CERT_FILE therefore
lost api.telegram.org (SSLCertVerificationError, live-probed).

Give the env bundle union semantics under its own cache key: the context
is the injected ssl.SSLContext (PROTOCOL_TLS_CLIENT: CERT_REQUIRED +
hostname checking) with the bundle loaded on top. A provider ssl_ca_cert
keeps its replace semantics through resolve_httpx_verify unchanged.
2026-10-06 12:25:01 +05:30
kshitijk4poor
0c889a774f fix(ssl): honour SSL_CERT_FILE in the shared Telegram TLS context
Gate finding C02-1 (High regression): before this stack Telegram passed
verify=True, and httpx built create_default_context(cafile=$SSL_CERT_FILE),
so a private CA supplied only through SSL_CERT_FILE was trusted.
platform_ssl_context() always returned the truststore platform context,
so on macOS that CA was rejected (probed on a real local TLS server).

When SSL_CERT_FILE names an existing file, return the cached bundle
context from _shared_context(path) — still one load per process, never
per reconnect. A missing or stale path keeps the platform context.
SSL_CERT_DIR (BASE used capath) stays uncovered.
2026-10-06 12:25:01 +05:30
luwen
785cf8a3de fix(telegram): reuse the shared platform TLS context for every Telegram client
Every Telegram httpx client (DoH fallback discovery, both PTB HTTPXRequest
pools, and each lazily rebuilt TelegramFallbackTransport pool) was built with
verify=True, so httpx ran ssl.create_default_context() and a full CA load on
the gateway event loop per client, per reconnect. A slow trust store stalled
the loop for most of the connect deadline on every attempt.

agent.ssl_verify already caches one platform-trust client context
(_shared_context(None)); expose it as platform_ssl_context() and pass it as
verify= to every Telegram client and transport. Verification is unchanged
(CERT_REQUIRED + check_hostname via the truststore-injected context), proxies
still flow through httpx's proxy/env handling, and the first build is ~3 ms
because the platform verifier loads no CA file.

This is the minimal slice of #133340: the PR's single-flight Future, worker
thread and to_thread client construction are unnecessary once the context is
cached and shared.

Fixes #133339
Salvaged from #133340.
2026-10-06 12:25:01 +05:30
kshitijk4poor
1e86b5240d refactor(compaction): name the pre-LLM summary skip in the fallback log
_fallback_summary_for_window logged "Feasibility skip" and the compress()
comment said "feasibility skip", but the flag is now also set when the
summary model is benched. Say "Pre-LLM summary skip" so the log matches
both causes. Wording only; no test asserts the old text.
2026-10-06 12:03:52 +05:30
kshitijk4poor
8fd87bd351 refactor(compaction): refresh stale skip/anti-thrash wording
record_completed_compaction carried two overlapping comments and logged
"via pre-LLM feasibility skip" although the path now also covers the
benched summary model; the anti-thrash comment still said it drops
"counters" when only the ineffective count is reset. Split the long
streak/skip tuple assignment in the stall-retry path and drop the
`is True` hedge (the flag is always a bool on the built-in compressor).
2026-10-06 12:03:52 +05:30
kshitijk4poor
1a8fb8c917 refactor(compaction): own the bench probe deadline load in PreLlmSkipMixin
The mixin already owns _set_fallback_probe_at and the model-config key;
the matching load lived inline in bind_session_state, which forced
context_compressor.py to import the key. Move it to
_load_fallback_probe_at() and make the key private to the mixin module.
2026-10-06 12:03:52 +05:30
kshitijk4poor
364319d956 fix(compaction): carry the bench probe deadline across rotating compaction
Rotating compaction (compression.in_place: false) calls on_session_start
with boundary_reason="compression"; bind_session_state zeroes
_fallback_probe_at and reloads it from the fresh child row, which has no
model-config key. The fallback streak was carried but the deadline was
not, so the benched summary model was re-benched with a new window on
every rotation and never probed again. Capture the deadline before the
rebind and persist it onto the child row alongside the streak.
2026-10-06 12:03:52 +05:30
kshitijk4poor
d6daf4bdd2 refactor(compaction): move pre-LLM summary skips into context_compressor_prellm
Code-health ratchet: agent/context_compressor.py grew past its cap on main.
Move _feasibility_skip, _fallback_streak_skip and _set_fallback_probe_at
(plus their constants) byte-identical into a PreLlmSkipMixin in the new
agent/context_compressor_prellm.py sibling, matching the existing
SummaryDispatchMixin layout, to keep context_compressor.py under its size
cap. No behaviour change; no test patches the moved names.
2026-10-06 12:03:52 +05:30
kshitijk4poor
5023d58671 fix(compaction): persist the summary-model bench probe deadline
The bench probe clock was in-memory monotonic time, so every rebuilt
compressor (the gateway rebuilds the AIAgent on cache eviction) came up
with a durable fallback streak but no clock, armed a fresh 300s window and
benched again. If rebuilds arrive faster than the window, the summary model
is never re-probed and the session stays on deterministic summaries until
a manual /compress.

Store the deadline as wall-clock time on the session row (model_config
key, the same durable path the proactive-prune runway uses) and load it in
bind_session_state, mirroring the anti-thrash recovery deadline (#100185).
A deadline more than one window in the future (clock jumped back) is
re-armed from now.
2026-10-06 12:03:52 +05:30
Elisa Martinez Abad
bdba7042a9 fix(compaction): bench a failing summary model instead of blocking compaction
Two fallback summaries in a row tripped the anti-thrash breaker even when
each fallback compaction was effective, so automatic compaction was blocked
and the session rode into the provider's hard context limit. Only
ineffective compactions now trip the breaker; a fallback streak >= 2 instead
benches the summary model (deterministic drop, one probe per recovery
window), which still stops the paid fallback loop from #63008. force=True
(/compress) always calls the model.

Salvaged from #129431.

Co-authored-by: Pascal Bérard <pberard@nvidia.com>
2026-10-06 12:03:52 +05:30
Teknium
22bc7eac71 refactor(solstice): rebuild the provider as a standard model-provider plugin
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
  to the generic PKCE flow as token_request. A refresh that NAS rejects
  (it folds every Google failure into 502) is treated as a dead grant
  needing re-login.
- transport.py: a GeminiNativeClient subclass on the
  generateContentPerUserQuota methods with Bearer auth (~30 lines, where
  the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
  default_aux_model).

Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
2026-10-05 23:33:41 -07:00
Teknium
f12e6b66d9 feat(auth): brokered PKCE, expiry-driven rotation and aux on plugin OAuth rows
Generic plugin-OAuth support that the Solstice provider needs. None of it
names a provider:

- OAuthPKCEConfig.token_request: a confidential client whose secret lives
  behind a broker hands the grant to the plugin in place of a token_url
  POST. The old refresh_token is kept when the response omits one, which
  Google does on refresh.
- Plugin OAuth rows rotate 2 minutes ahead of their stored expires_at_ms
  (shared with the anthropic rule). Opaque bearers such as Google's ya29.*
  carry no JWT exp, so this stored expiry is the only clock they have.
- A row whose refresh grant just went DEAD stays DEAD on the inference 401
  that follows; before this it was re-benched EXHAUSTED and the dead token
  was replayed every cooldown.
- The auxiliary client serves an OAuth plugin that ships create_client from
  its pooled row on the main runtime's endpoint (compression, titles). It
  used to return "not directly supported".
- A 401 on a plugin OAuth row prints a re-sign-in hint, not the API-key
  advice.
- The Gemini native adapter exposes its method names, auth headers and
  error builder so a Bearer-auth subclass can reuse it without the API-key
  remedies.
2026-10-05 23:33:41 -07:00
Ben Barclay
8e015f483f feat(providers): add Solstice as a secret-launch OAuth inference provider
Per-user-quota OAuth inference for Hermes Agent, hidden by default until
public launch is cleared. Auth (OAuth2 + PKCE with the user's account) is
brokered by nous-account-service, which holds the confidential client secret;
inference goes direct to the per-user-quota endpoint.

Built fresh on current main; commit history carries only the codename.

Provider surface:
- plugins/model-providers/solstice/ — profile + hidden enable gate
- hermes_cli/solstice.py — PKCE loopback login, NAS-brokered config/exchange/
  refresh, token store, quarantine; callback path unchanged
- agent/solstice_adapter.py — per-user-quota transport over the native
  adapter's OpenAI-compatible facade
- providers/: _HIDDEN_GATES + register_hidden_provider_gate + a hidden filter
  in list_providers (kept out of the picker/registry/doctor until enabled)
- wired into main.py (_PROVIDER_MODEL_FLOWS), auth_commands.py (_PROVIDER_ALIASES,
  _OAUTH_ADD_SPECS, _is_known_provider allowlist), models.py (catalog fetcher),
  runtime_provider.py (resolution rung), model_setup_flows.py, agent_runtime_helpers.py

Kept verbatim (the live wire contract, until the broker is renamed): the
inference host, the OAuth scope, the deployed broker routes, and the
registered redirect callback path.

The enable gate is unchanged (open item: gate on stored credentials).
Verified: 226 focused tests pass; runtime resolution, client construction
and auth-add all exercise the real paths.
2026-10-05 23:33:41 -07:00
Brooklyn Nicholson
665a42bb08 fix(auth): serialize MiniMax OAuth refresh and keep it a credential-only write
teknium1's request-changes review of #133534, all four findings plus nits:

1. Concurrent refresh logged the user out (major): _minimax_fresh_state now
   holds _auth_store_lock across read -> refresh -> save and re-reads state
   inside the lock, so a second concurrent consumer (main agent + aux task)
   sees the first caller's new expiry instead of replaying the single-use
   refresh token into refresh_token_reused and quarantining the winner's
   fresh tokens. Lock timeout outlives the refresh POST (codex pattern).
2. Aux refresh overwrote active_provider (medium): _minimax_save_auth_state
   gains set_active; the refresh and quarantine paths persist with
   set_active=False (the _save_provider_state_to_source rule), only
   _minimax_oauth_login sets active.
3. Warning spam when logged out (medium, #21521's complaint): the arm's
   expected absent/not-logged-in/AuthError fall-throughs are debug-once
   (contract test_auxiliary_client_resolve_dedup.py); only a genuinely
   unexpected exception keeps exc_info, logged once.
4. Probe mode made a network call (minor): under _aux_probe_active() the arm
   reads get_provider_auth_state("minimax-oauth") raw and stubs when
   access_token + inference_base_url are present, skipping the refresh
   (mirrors credential_pool.py::_seed_minimax_singleton).

Nits: the arm body lives directly in auxiliary_client_registry.py (one new
module, agent/auxiliary_client_minimax.py deleted), the in-tree
try/except ImportError wrappers are gone (only the build_anthropic_client
SDK-absent boundary remains, matching _try_anthropic), and the misnamed
..._with_oauth_semantics test is now ..._third_party_wire.

Tests: concurrent loopback-portal refresh race (1 POST, 0 errors, tokens
intact), aux refresh preserves active_provider, login still sets it,
logged-out resolutions are debug-once without traceback, probe mode with an
expired token makes 0 HTTP calls.

Co-authored-by: Ubxty <ubxty@users.noreply.github.com>
2026-10-05 22:38:27 -05:00
Brooklyn Nicholson
d61d60d493 refactor(auxiliary): registry auth-type table; MiniMax OAuth arm in a topical sibling
Clears the code health ratchet on the branch:

- FILE_LINES: agent/auxiliary_client.py 8469 > 8434 (main's cap, may only go
  down). The PR's new MiniMax OAuth arm moved out of the facade into
  agent/auxiliary_client_minimax.py, following the <stem>_<topic>.py sibling
  layout; the arm late-imports the facade so the seams tests patch
  (hermes_cli.auth.resolve_minimax_oauth_runtime_credentials,
  agent.anthropic_adapter.build_anthropic_client) are exactly where
  production reads. Facade now 8432 lines.
- HX011: the auth_type if/elif ladder in _resolve_registry_branch became a
  dict -> handler table (REGISTRY_AUTHTYPE_ARMS in
  agent/auxiliary_client_registry.py, the registry twin of
  _EXPLICIT_PROVIDER_BRANCHES). Behavior identical: same arms, same
  log-once fall-throughs on the facade.
- BLE001: the arm's blind except Exception around the runtime credential
  resolution is a deliberate absent-credentials boundary; it now logs with
  exc_info=True so the traceback surfaces (verified clean under the pinned
  ruff with --ignore-noqa; no waiver comments).

No behavior change: the 3 PR tests, test_auxiliary_client.py (240),
test_anthropic_route_oauth_identity.py, test_auxiliary_client_resolve_dedup.py,
test_missing_provider_credentials_hint.py, bedrock/vertex/minimax suites all
green; scripts/check 11 checks ok.
2026-10-05 22:38:27 -05:00
Brooklyn Nicholson
70d6e83563 fix(auxiliary): MiniMax OAuth route keeps third-party wire contract
Review fix for the oauth_minimax arm (ehz0ah, #109316): is_oauth on
AnthropicAuxiliaryClient is not 'credential came from OAuth' — it enables
Claude Code-native request behavior (mcp__ tool-name wire prefixing,
identity/system rewrites, response prefix stripping) that only
api.anthropic.com routes qualify for. api.minimax.io is a third-party
Anthropic-protocol endpoint and anthropic_route_is_oauth(base_url,
token_provider) is False for it by contract
(tests/agent/test_anthropic_route_oauth_identity.py), so the hardcoded
True sent mcp__-prefixed tool names to a host that never round-trips
them.

Derive the flag via anthropic_route_is_oauth(base_url, token_provider)
instead — same rule the custom anthropic_messages arms already use —
keeping the callable bearer (per-request token refresh) intact.

The regression test asserted the wrong invariant; it now pins
_is_oauth False, and a new witness test proves tool names reach the
wire unprefixed (read_file stays read_file, not mcp__read_file;
session_search is not aliased).
2026-10-05 22:38:27 -05:00
ellannjohnson
8183689c43 fix(auxiliary): support MiniMax OAuth side tasks
_resolve_registry_branch now has an oauth_minimax arm that resolves a
callable bearer via resolve_minimax_oauth_runtime_credentials and wraps
the resulting Anthropic SDK client in AnthropicAuxiliaryClient(..., is_oauth=True).
Without it, minimax-oauth falls through to the oauth_device_code/oauth_external
branch and silently returns (None, None), re-routing every aux task pinned
to that provider to the Step-2 fallback chain.

The callable bearer mints a fresh access token per outbound request because
MiniMax's tokens live ~15 minutes and a static string would 401 mid-session.
2026-10-05 22:38:27 -05:00
Teknium
68420e1912 refactor: move aux-slot growth into topical siblings (code-health caps)
No behavior change. Keeps every touched file and function at or below
its value on main:

- agent/auxiliary_task_config.py: read-time `auxiliary.<task>`
  resolution (plugin defaults, inherit_from, whole-route pin); still
  re-exported as agent.auxiliary_client._get_auxiliary_task_config.
- hermes_cli/plugins_aux_tasks.py: register_auxiliary_task validation
  and entry shape.
- web/src/lib/api-aux.ts: /api/model/auxiliary wire types.
- Desktop: aux row list, label resolver, deep-link highlight, route
  summary, Follow button and the set-to-main route in
  settings/aux-task-rows.tsx; one assignAuxiliary callback replaces
  setAuxiliaryToMain. Built-in aux slot labels move from each locale
  into <locale>_aux_tasks.ts (types_aux_tasks.ts holds the shape).
2026-10-05 17:24:40 -07:00
Teknium
952322ca3c fix(plugins): inherited aux slots follow their base until the user pins one
Salvage follow-up for #129189 on top of #129209:

- An unknown or self-referential `inherit_from` now logs a warning and
  registers the task without inheritance instead of raising, so one bad
  base no longer fails the whole plugin load (the #129191 approach).
- `provider: auto` on an inheriting task means "no preference", like the
  empty strings: "Reset all to auto" (CLI + dashboard) writes exactly that,
  and before this it pinned the slot to the main provider while the model
  was still inherited from the base (mismatched route).
- Once the user pins a route on the task (non-auto provider, model or
  base_url), provider/model/base_url/api_key/api_mode/key_env/effort all
  come from the task's own block. Mixing per key let a pinned provider pick
  up the base's base_url and api_key.
- The CLI picker resolves the inherited view only for inheriting tasks.

Tests collapse to behavior contracts: base followed and changes
propagate, picker placeholders keep following, a pin wins whole, bad
bases warn and are ignored, re-registration cycles are cut.

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-10-05 17:24:40 -07:00
Apostol Apostolov
5999ecb75d fix(plugins): keep an inherited aux base from being shadowed by write-only empties
_get_auxiliary_task_config merged the raw auxiliary.<key> block over the
inherited base, and Hermes's own config writers persist model, base_url,
api_key and reasoning_effort as "" when the operator expresses no preference
for them. _save_aux_choice writes those four fields unconditionally, and
_reset_aux_to_auto clears them for every known task including plugin-registered
ones via _all_aux_tasks(). So picking a provider for one task, or hitting
"Reset all to auto" once, left every routing key resolving to the empty string,
which maps to None and falls back to the main agent model. That discarded the
inherited base for exactly the keys the operator UI writes, which is the whole
point of inherit_from.

Filter "" out of the user layer when a task actually inherits. Only "" is
dropped, never any falsy value: provider "auto" is a deliberate operator choice
and reasoning_effort false is an explicit request for no reasoning. Tasks
without inherit_from keep today's behaviour, including their empty placeholders.

The picker also rendered the raw stored block, so a task inheriting a base's
provider reported "auto" while actually running on that provider. Display now
resolves the inherited view for inheriting tasks only, and falls back to the
stored block whenever the runtime resolver is unavailable.

Reported-by: Enough1122 (AI code review on this PR).

Refs #129189
2026-10-05 17:24:40 -07:00
Apostol Apostolov
be3f0fcc1c feat(plugins): let auxiliary tasks inherit a base slot at read time
register_auxiliary_task's defaults start from a fixed shape and every key
is a full replace, so a plugin whose side model is a near-copy of a
built-in has to restate the whole block and silently drifts when that
built-in's shape changes.

inherit_from names a built-in or already-registered task whose effective
config becomes the base, resolved at read time so the plugin tracks the
base's current values instead of snapshotting them. Precedence is base,
then plugin defaults, then user config.

Self-referential inherit_from is rejected at registration rather than
left to the read-time guard: same-owner re-registration is allowed, so
the bad value would otherwise surface as unbounded recursion in a config
read. The guard stays for a hand-edited registry.

Closes #129189
2026-10-05 17:24:40 -07:00
Teknium
561a1e8a67 fix(telemetry): a host-timeout compression cancel counts as failed, not skipped
explicit_interrupt was emitted for every AuxiliaryExplicitCancellation, including
the host cancelling the commit fence on timeout (gateway hygiene 30 s budget, the
600 s total ceiling while still streaming). Those are real failures, but the
skipped set made them read as a user stop. Use explicit_interrupt only when the
user's stop event is set; a fence cancel without it reports
commit_fence_cancelled (already a failed class).

The generic-verdict deferral now defers only to the current attempt's recorded
class, so a snapshot restore cannot hand it the previous attempt's class.

The attempt emitters move to agent/conversation_compression_telemetry.py (same
logger name) so the facade stays under its line cap.
2026-10-05 16:18:47 -07:00
abderbj
4d70946d3b fix(compression): attempt log keeps the compressor's failure_class
The attempt-telemetry emitter let the caller's generic abort verdict
overwrite the class the compressor recorded for the same outcome. A
transcript that fits inside the tail budget was logged as "no_progress"
instead of "no_compressible_window" (#131412), and a terminal summary
failure as "summary_generation_aborted" instead of its documented class
("summary_auth_failure", "summary_overload_failure", ...).

Only those two caller labels defer to a recorded class; every other label
names an event the compressor cannot see (fence cancelled, superseded,
rollback, pool saturated) and still wins. The shared-metrics sink receives
the same resolved class as the log line.

Refs #131412
2026-10-05 16:18:47 -07:00
Teknium
1e866e34bf fix(aux): the first-token window honours providers.<id>.stale_timeout_seconds
The pre-first-token window used the env stale base, so an explicit per-provider
stale timeout was ignored (900 configured, window capped at 180). It now goes
through the main loop's _cloud_stale_timeout_for with the attempt's routed
provider, so the explicit setting wins exactly as it does for the main loop.
2026-10-05 16:00:18 -07:00
Teknium
636b9c608c fix(aux): the chat-stream watchdog cuts only its own unfinished stream
Review fixes for the #100501 chat-stream no-progress watchdog:

- The no-progress window is inter-chunk. Before the first token a remote
  endpoint gets the main loop's cloud stale patience (_cloud_stale_timeout:
  context-scaled, reasoning-model floor), capped at the request timeout, so
  o3 / deepseek-r1 / a MoA reference slot thinking silently is not cut at 60s.
- The watchdog pins its attempt's socket at construction and fences the
  response body's release under its lock; the timer decides and shuts down
  under the same lock with a generation token. A timer that already woke can
  no longer reset a later request that reused the keepalive connection.
- The default relay callback (recovery rungs, credential retries) passes the
  call's task, so auxiliary.<task>.no_progress_timeout survives the retry.
- A stall after the terminal chunk (finish_reason / usage received) keeps the
  completed response and its billed usage instead of raising into fallback.
2026-10-05 16:00:18 -07:00
Teknium
1259068415 fix(compression): fail a silent chat-completions summary stream at the no-progress window
A streamed auxiliary call over chat-completions (every OpenAI-compatible
compression route: custom/vLLM/proxy, OpenRouter, ...) blocked inside the SDK
chunk iterator until the httpx read timeout (the full auxiliary budget, >= 300s
for compression). The host compression wait gave up first, so the attempt
ended as stall_interrupted / commit_fence_cancelled with no fallback. Only the
Codex Responses wire had a no-progress watchdog.

Arm the same 60s substantive-progress window (auxiliary.<task>.no_progress_timeout)
on the chat stream: a Timer shutdown()s only this attempt's socket (FD-safe
from a stranger thread, #70773), the owner closes the stream in its finally and
raises a "stalled ... timed out" TimeoutError, so the existing retry/fallback
ladder runs. Local endpoints keep silent prefill on the request timeout. The
fallback warning names no_progress_timeout instead of the request timeout.

Fixes #100501

Co-authored-by: fangliquanflq <fangliquan@qq.com>
2026-10-05 16:00:18 -07:00
Teknium
1f11e7fa23 fix(plugins): bound and cycle-check the concurrent plugin load wait
Review fixes for the per-module plugin load lock:

- A second caller waits at most 10 s for another thread's load of the same
  module, then refuses with a named warning and returns None. While that
  import is still stuck, later callers refuse at once. A hung provider import
  no longer blocks every gateway turn, and there is no inline fallback load.
- The wait polls a wait-for graph spanning the loader locks and importlib's
  own module locks. When thread A holds a helper's import lock and waits on
  the plugin while thread B loads the plugin and imports that helper, the
  cycle is detected and the waiter takes the partial module, the same way
  importlib handles a concurrent circular import. Before, both threads hung.
- The lock is still a same-thread RLock with no worker thread, so recursive
  loads and ContextVars are unchanged.
- conversation_compression.py: compact the fork-checkpoint exemption so the
  file stays under its line cap after the rebase.
2026-10-05 15:55:58 -07:00