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.
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
* 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>
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>
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
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).
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).
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).
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.
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.
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).
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).
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).
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.
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.
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.
_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.
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).
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.
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.
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.
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.
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>
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.
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.
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.
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>
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.
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).
_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.
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).
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>
_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
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
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.
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
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.
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.
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>
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.