Commit Graph

4224 Commits

Author SHA1 Message Date
kshitijk4poor
4c8d4148ca test(aux): pin that a scoped runtime's key rotation stays out of the legacy mirrors
e9a77f4188's `_RUNTIME_MAIN_API_KEY == old` guard had no test: reverting it left every kept test
green, though rotating under scoped_runtime_main then raises KeyError 'base_url' (or, with the
.get() default, overwrites the turn's mirrors with the scoped copy). Extend the existing aux-route
test with the scoped rotate step; red with the guard removed (mirror model becomes 'm') and red on
the pre-guard code (KeyError), green on HEAD.

Fold finding C15 #2 (gate 2ab toothless guard).
2026-10-07 13:20:42 +05:30
kshitijk4poor
b6a19d6262 fix(anthropic): keep the fallback-restore snapshot's OAuth flag paired with its refreshed key
The credential refresh moved _primary_runtime["anthropic_api_key"] to the new token but left
_primary_runtime["is_anthropic_oauth"] at the old token's value, while self._is_anthropic_oauth is
recomputed from the new token. _build_anthropic_client_from_runtime restores the key and flag
together after a fallback, so a refresh that changed token type (Console key -> OAuth) would
restore the new key with the stale flag. Update the flag under the same identity guard.

Fold finding C15 #1 (gate quality review).
2026-10-07 13:20:42 +05:30
kshitijk4poor
3ad035cb2e test(anthropic): compression before any request sends the refreshed token
Manual /compress and turn-start compaction run before the next main request, so the
holder sync inside the main-request refresh never fired and the summary call carried the
revoked token. Replaces the bare holder-move test (its assertions are now checked through
compress_context), keeping the stack at two tests. Red without the pre-compression refresh.

Co-authored-by: Atul <atul@trycoral.ai>
2026-10-07 13:20:42 +05:30
kshitijk4poor
d79480f128 fix(anthropic): rotate the published aux key in place so the turn thread sees it
Gate finding B15-1 (Medium): the pre-dispatch refresh runs inside the request
worker's copied Context (_context_thread_target -> contextvars.copy_context().run),
so re-calling _publish_runtime_main rebound the ContextVar only in that copy; the
turn thread's later `auto` aux calls (approvals, goal judge, plugin llm) still read
the revoked token. rotate_runtime_main_api_key mutates the already-published dict
(shared by both contexts) when it still holds the old key, then refreshes the
legacy mirrors. No cross-session leak: set_runtime_main builds a fresh dict per
turn, so the shared object is this turn's only.

The kept e2e test now runs the refresh on a thread under _context_thread_target;
red on the previous head, green here.
2026-10-07 13:20:42 +05:30
kshitijk4poor
e8f524d7ea test(anthropic): drop the stray third blank line after the aux-route test
Gate finding B15-2 (Low): three blank lines before TestOAuthFlagOnCredentialSwap
where the file uses two between classes.
2026-10-07 13:20:42 +05:30
kshitijk4poor
44c269f1eb fix(anthropic): republish the same-turn aux runtime after a token refresh
turn_context publishes the auxiliary main runtime at turn start, before the
first request can trigger the silent Anthropic refresh. Syncing the agent-side
holders alone left that already-published runtime on the revoked token, so
same-turn `auto` aux calls (approvals, goal judge, plugin llm) still 401d.
Re-call _publish_runtime_main when the published key is still the old token;
identity-gated so a fallback or other-route publish is never overwritten.

Also keep #121757's end-to-end aux-route test, rewritten to production
order (publish, then refresh, then read).

Co-authored-by: SoyMarketing <yo@soymarketing.org>
Co-authored-by: mahdi <mahdi.nikfallah@yarnhub.org>
2026-10-07 13:20:42 +05:30
mahdi
4b842d3f25 fix(anthropic): move every holder of the revoked token on credential refresh
Claude Code revokes the old OAuth token when it refreshes, but
_try_refresh_anthropic_client_credentials only updated _anthropic_api_key.
agent.api_key, context_compressor.api_key (sent as the aux main_runtime for
compression) and the _primary_runtime restore snapshot kept the revoked
token, so every compression summary 401d for the rest of the session.
Sync each holder that still carries the old token (identity-matched, so a
compressor or snapshot on another route keeps its own key).

Rebuilt by hand on main from #121859 (conflicted in the test file).

Fixes #121756

Co-authored-by: SoyMarketing <yo@soymarketing.org>
2026-10-07 13:20:42 +05:30
kshitijk4poor
d0c7bb9432 fix(providers): review fold for the LM Studio / TokenHub profiles
- Plugin hooks written against the older build_api_kwargs_extras keywords (no **context) no
  longer raise TypeError on every request: lmstudio_reasoning_options is forwarded only when the
  agent probed it (LM Studio). Pinned by TestProfileHookKeywordCompat, which fails if the field is
  passed unconditionally.
- tencent-tokenhub: drop base_url and supports_model_listing=False. With them, first-time
  `hermes model` setup reached the profile path and offered only the 3 curated Hy models instead
  of probing {base}/models as main does; without a profile base_url, setup and the /model picker
  take the same paths they took with no profile.
- One name for LM Studio's probed options end to end (lmstudio_reasoning_options), matching the
  other provider-prefixed hook keys (ollama_num_ctx, qwen_session_metadata).
- New base-url:github-models-chat snapshot cell (openai/o4-mini, asserted chat_completions): the
  gpt-5.5 cell goes to Responses and never reached the legacy GitHub reasoning branch.
- Comment/docstring truth: the host branches serve agents with no registered profile (not only
  base_url-only ones); the LM Studio effort omission is done by the profile.
2026-10-07 11:27:35 +05:30
kshitijk4poor
4c84ff744e refactor(providers): LM Studio and Tencent TokenHub get provider profiles; base_url-only agents get snapshot cells
lmstudio and tencent-tokenhub were the last chat-completions providers with no ProviderProfile,
so their requests were built by the legacy flag branch in ChatCompletionsTransport.build_kwargs
(name check for LM Studio, host check for TokenHub). They now have profiles that reproduce the
same wire through build_api_kwargs_extras:

- lmstudio: top-level reasoning_effort from the model's published allowed_options. The agent's
  cached probe is passed to the hook as reasoning_options; auxiliary calls (no probe) keep the
  generic nested reasoning they already sent. base_url is left empty on purpose: a loopback
  base_url would map every 127.0.0.1 / localhost endpoint to LM Studio in URL-to-provider
  inference and turn off local-server detection for Ollama, llama.cpp and vLLM. Model listing
  and the doctor probe stay off so the picker keeps LM Studio's own chat-model probe.
- tencent-tokenhub: top-level reasoning_effort clamped to low/medium/high, omitted when thinking
  is off, high when no level is chosen (default_reasoning_config on main-loop turns). Model
  listing and the doctor probe stay off so the picker keeps the curated Hy list.

The name-based LM Studio branch is removed from the legacy path. The host-based branches stay:
an agent built from a base_url alone (run_agent.py main, the batch runner, library callers)
carries provider="" and still reaches the legacy path. 11 base_url-only snapshot cells now pin
those host-detected requests (OpenRouter, Nous, Kimi, Kimi coding, TokenHub, GitHub Models,
NVIDIA NIM, Qwen portal, a localhost LM Studio port, Ollama, a generic endpoint) so the change
that retires the legacy path can prove it sends the same requests.

Deliberate deltas: model ids prefixed lmstudio: / tencent-tokenhub: are now stripped like every
other profiled provider's; TokenHub auxiliary calls with a reasoning config send top-level
reasoning_effort (the main-loop shape) instead of nested extra_body.reasoning.
2026-10-07 11:27:35 +05:30
kshitijk4poor
716bab6967 test: close the review gaps on the merged relay, classifier and prompt-cache tests
Review threads left open on three merged PRs, each a gap in what the tests actually guard:

- #133740 (relay usage merge): no checked-in test failed on the base. The merged fix keeps
  message_start's input/cache counts when the SDK's MessageDeltaUsage serializes them as null,
  but the existing message_delta test only sent output_tokens, so it passed with the old merge
  too. New test_anthropic_stream_accumulator_null_delta_usage_keeps_message_start_counts sends
  nulls for input and cache fields; it fails with the old `{**current_usage, **usage}` merge.
- #133767 (error-body table): the #49769 cell asserted the 402 would be reclassified as
  retryable. The fix that landed (#134157) keeps it `billing` and retries in the main loop's
  recovery chain, so the cell still XFAILed on main and would pass even if the retry broke.
  It now pins what the recovery depends on (billing + a parseable budget); the retry itself is
  driven end to end by test_affordable_402_main_loop.py.
- #133767 (prompt-cache contract): the marker check needed cache_control in only one request,
  so a later request that lost its markers passed. It now requires markers on every request.
  The module docstring claimed native Anthropic layout coverage the cells don't have; it now
  says envelope layout.
2026-10-07 11:26:41 +05:30
Teknium
a3ab152cd5 fix(tools): an image-bearing MCP result keeps its incomplete-data notice (review)
Greptile on a6b51f6ed4e (5/5, non-blocking): `_detect_upstream_elision`
only scans strings, so once an MCP result also carries an image it reaches
`make_tool_result_message` as a part list and an elided payload
("... 13 more items", has_more:true) lost the "data shown is INCOMPLETE"
notice. A part list is now judged by its first text part, and the notice
is appended there, inside the untrusted wrapper; the image is untouched.
2026-10-06 22:54:13 -07:00
Brooklyn Nicholson
82732650a1 refactor(agent): move dead-connection sweep into a runtime-helpers sibling
Keeps agent_runtime_helpers.py within its code-health line cap and logs the
best-effort probe/cleanup failures with their traceback (BLE001).
2026-10-07 00:07:02 -05:00
Kenshi
cf6fada002 fix(agent): clean cached request pool sockets (#91286) 2026-10-07 00:07:02 -05:00
Brooklyn Nicholson
3bbe2a1ce2 fix(copilot): recompute api_mode on fallback and stop stale-mode veto (#46527)
Port of #46542 by @MarkVLK onto current main:

- hermes_cli/runtime_provider.py: _copilot_runtime_api_mode must not veto
  a stale configured codex_responses mode for chat-only Copilot models;
  recompute from the live model capabilities.
- agent/transports/codex.py: request summary:"auto" on the GitHub
  Responses API so reasoning summaries surface.
- agent/agent_init.py: recompute api_mode at init-time fallback so the
  fallback provider/model gets its own mode (and the primary-runtime
  snapshot reflects it).
- tests: regression tests for the stale-mode veto, the GitHub Responses
  summary, and the init-time fallback recompute.

Conflicts resolved against main's refactored fallback ladder
(agent/chat_completion_helpers.py) and its expanded transport tests.

Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Fixes #46527
2026-10-06 23:54:51 -05:00
kshitijk4poor
ee8dd6c886 fix(agent): a credit-limited 402 retries once with the affordable output cap instead of failing as billing
OpenRouter answers a request whose max_tokens exceeds the remaining balance with
"402 ... You requested up to 65536 tokens, but can only afford 56272". The account has
credit; only the cap was too large. The main loop classified it as terminal billing and
failed the turn (or rotated off a credential that still had credit), so a request that
needed a few hundred tokens was dropped (#49769).

recover_after_classification now retries once with the output cap lowered to the affordable
budget before pool rotation, reusing the auxiliary client's _affordable_max_tokens_from_error
parse (same 64-token margin and 512-token floor) and the existing one-shot
_ephemeral_max_output_tokens seam. A second credit-limited 402 on the same call, or a body
with no parseable budget, keeps today's billing handling. Classification is unchanged.

Approach from #49785 by @tcconnally (closed unmerged), applied at the recovery layer.

Co-authored-by: Thomas Connally <51974392+tcconnally@users.noreply.github.com>
2026-10-07 06:41:07 +05:30
Teknium
911da42e9e refactor(security): remove the bundled tirith scanner from core
tirith ran `tirith check` on every terminal command, default-on with fail-open, as a
pinned PM binary auto-downloaded at startup. Measured against the shipped 0.4.2 binary
it caught 14/16 attack commands but flagged 12/24 everyday agent commands
(`echo $((6*7))`, `"$VAR/x.sh"`, `gh api | python3`, LAN curls, `.dev` hosts,
`pip install` via analysis_incomplete), and in cron/-q deny mode each of those is a
hard block. It only ever saw terminal strings (execute_code, writes and the browser
bypass it), fail-open meant no protection wherever the binary could not install
(Windows, Termux, x64 musl), and it produced a steady issue/PR stream (fail_open
semantics four times, install loops filling disks, a SIGSEGV hang, session-approval
scoping, third-party promo text on approval cards).

Removed: tools/tirith_security.py, the tirith branches of the command guard (the
is_tirith tuple, session-only "always" downgrade, import-error/fail-open paths,
unattended tirith pass), the CLI and gateway startup installers, the PM package and
lock pins, the nix runtime dep, security.tirith_* defaults, the CLI warning string
and three tirith tips (gaps refilled from the tail so translations survive), plus
the gateway's "no automated assessor" warning that only existed to point at tirith.

Config v50 drops security.tirith_* and enables nothing in its place. Hermes ships
no tirith plugin; if tirith upstream publishes one, it goes through normal catalog
admission under their name.
2026-10-06 14:22:13 -07:00
Siddharth Balyan
de1626d666 Free tier: a rate limit ends a watched turn at once with the reset time (#134049)
* fix(agent): free-tier rate limits end an attended turn at once

A free-tier (no sign-in) session that hit the service-wide rate limit slept
up to 600 s, retried into the same refusal, slept again, and then sent its
third attempt on a guest JWT that had expired during the wait. The welcome
host answered 403 "invalid jwt", which read as the tier being switched off.
The user watched a spinner for about 18 minutes and then saw the wrong card.

- An attended session (desktop, TUI, the interactive CLI, ACP) on the free
  model ends the attempt cycle at once when the cooldown is longer than
  60 s: fallback first, else the reset time and the sign-in / model doors.
  "Attended" is keyed on the session source, so `hermes chat -q`, cron and
  messaging sessions keep the long wait. The source vocabulary moves from
  run_agent.py into agent/session_source.py, which adds is_attended().
- on_free_model() (welcome host + anonymous credential) is the one free-tier
  check, used by the cutoff, the "free model is busy" wait copy and the
  outage copy.
- A welcome-host 403 "invalid jwt" is its own route kind, session_expired:
  the existing one-shot route heal re-exchanges the credential and retries;
  if that fails the card is the retryable outage card.
- interruptible_backoff_sleep adopts a fresh Nous key after each wait.
  _adopt_nous_key_before_expiry owns its refresh failure, so both call
  sites drop their try/except.
- A retry wait over 60 s is announced in the transcript when it starts, for
  every provider; shorter waits stay buffered. Every wait line names the
  attempt that runs after it.
- max_retries_exhausted_result's max_retries becomes attempts (what it
  carries); a one-attempt rate-limit end gets its own lead.
- One Retry-After reader, retry_utils.provider_retry_after_seconds, shared
  by compute_error_backoff and the recovery ladder.
- _session_default_route replaces _session_default_model everywhere in
  tui_gateway, so a not-yet-built free-tier session reports nous/welcome on
  create, reuse, resume, branch and row persist, not the silent default.

* fix(agent): free-tier cutoff no longer ends subagent or library turns, paid waits look like main (review)

A delegated child of a TUI/Desktop session and a library caller (no platform) are unattended, so they keep waiting.
Rate-limit wait lines keep main's attempt numbers and stay buffered for every provider; only a non-rate-limit Retry-After over 60 s is announced, as on main.
The unattended free-tier wait line keeps the reset time beside "The free model is busy".
The free-tier cutoff names the reset time for any cooldown it fires on (over 60 s), not only from 120 s.
session.info for an env-seeded model off the free tier carries the model alone again, as on main.
Behaviour tests for the cutoff, the subagent and unattended waits, and a paid long wait.
2026-10-06 12:31:21 -07:00
kshitijk4poor
da80c3da1c test: the skill scaffold prefix cell is a plain regression test now that #133715 landed
The fix merged as 90194c64ff, so the run-time xfail for #81867 would only ever
pass. Drop the known_failure wrapper and reword the module docstring from
"gap" to "regression". The cell is red with the pre-fix agent/prompt_caching.py
(messages[1] goes from parts to a plain string) and green on main.
2026-10-07 00:59:51 +05:30
kshitijk4poor
1f79eb6450 test: pin the wire every provider receives, real error bodies, and the prompt-cache prefix contract
Three regression nets, each built from production evidence:

- tests/agent/transports/test_provider_wire_snapshot.py + tests/fixtures/provider_wire_snapshot.json:
  the request every registered provider is sent (51 providers x reasoning unset/high/off), built
  through the production assembly path (build_api_request: cache decoration, sanitization,
  transport kwargs, Codex preflight, middleware). A shared-code edit that changes what any
  provider receives fails naming the provider and the first diverging key; a new provider fails
  until its wire is recorded. Network and DNS are blocked so capability probes (Ollama
  /api/show, LM Studio, catalogs) record their offline answer, not a live server's.
  Mutation check: +1 on the shared timeout fails 38 providers.

- tests/agent/test_error_classifier_observed.py: error bodies Hermes actually received (relay
  traces and agent.log, scrubbed), raised as the real openai/httpx exception types. Two known
  gaps are run-time xfails that flip to passing when their fix lands: #49769 (OpenRouter
  "can only afford N" 402 classified as terminal billing) and #85005 ("not a multimodal model"
  not recognised as an image rejection).

- tests/agent/test_prompt_cache_prefix_contract.py: a real AIAgent on a caching route against
  the recording fake provider; every request must re-send the previous request's content and
  only append. Pins a live cache bust as a run-time xfail: the #81867 skill-scaffold split is
  applied only while the skill message is inside the last-N marker window, so request 3 of a
  skill session re-sends the scaffold as a plain string and re-writes it (~20k tokens on call 2
  of skill-invoked sessions in agent.log).
2026-10-07 00:59:51 +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
teknium1
781334eea4 chore(memory): remove the bundled mem0 provider (moved to the plugin catalog)
plugin-catalog/mem0.yaml (#131267) points at mem0ai/mem0's own
integrations/hermes-plugin-mem0 at a reviewed pin. The entry stays dormant
while a bundled copy exists (bundled providers win in find_provider_dir), so
deleting the in-tree copy is what lets hermes_cli/memory_provider_migration.py
install it for every home whose memory.provider is "mem0". The vendor copy
keeps the provider name, mem0.json, the MEM0_* keys and the setup wizard.

Also dropped: the hermes-agent[mem0] extra, its win-arm64 platform row and
exclude-newer row, the pm/extras.py anchor (uv lock removes only mem0ai,
posthog, qdrant-client and sqlalchemy), the mem0-specific doctor check (the
generic check now names `hermes plugins install mem0`), and the tests that
imported plugins.memory.mem0. Prose that lists the bundled set now names mem0
as a catalog provider. MEM0_* env hints and redaction stay: the catalog copy
still reads them.
2026-10-06 05:50:08 -07: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
kshitijk4poor
93cbf617c7 revert: back out #133714 (merged accidentally; still under review)
PR #133714 ("test: pin each provider's wire, real error bodies, and the
prompt-cache prefix contract") was merged before review and discussion
finished. This reverts both of its commits so main is back to the state
before it landed:

- 74306c1d1b test: the skill scaffold prefix cell is a plain regression test now that #133715 landed
- 560e53638c test: pin the wire every provider receives, real error bodies, and the prompt-cache prefix contract

Test-only revert: removes the three test files and the wire snapshot
fixture; no production code is touched. Discussion continues on #133714.
2026-10-06 14:45:29 +05:30
kshitijk4poor
74306c1d1b test: the skill scaffold prefix cell is a plain regression test now that #133715 landed
The fix merged as 90194c64ff, so the run-time xfail for #81867 would only ever
pass. Drop the known_failure wrapper and reword the module docstring from
"gap" to "regression". The cell is red with the pre-fix agent/prompt_caching.py
(messages[1] goes from parts to a plain string) and green on main.
2026-10-06 14:22:56 +05:30
kshitijk4poor
560e53638c test: pin the wire every provider receives, real error bodies, and the prompt-cache prefix contract
Three regression nets, each built from production evidence:

- tests/agent/transports/test_provider_wire_snapshot.py + tests/fixtures/provider_wire_snapshot.json:
  the request every registered provider is sent (51 providers x reasoning unset/high/off), built
  through the production assembly path (build_api_request: cache decoration, sanitization,
  transport kwargs, Codex preflight, middleware). A shared-code edit that changes what any
  provider receives fails naming the provider and the first diverging key; a new provider fails
  until its wire is recorded. Network and DNS are blocked so capability probes (Ollama
  /api/show, LM Studio, catalogs) record their offline answer, not a live server's.
  Mutation check: +1 on the shared timeout fails 38 providers.

- tests/agent/test_error_classifier_observed.py: error bodies Hermes actually received (relay
  traces and agent.log, scrubbed), raised as the real openai/httpx exception types. Two known
  gaps are run-time xfails that flip to passing when their fix lands: #49769 (OpenRouter
  "can only afford N" 402 classified as terminal billing) and #85005 ("not a multimodal model"
  not recognised as an image rejection).

- tests/agent/test_prompt_cache_prefix_contract.py: a real AIAgent on a caching route against
  the recording fake provider; every request must re-send the previous request's content and
  only append. Pins a live cache bust as a run-time xfail: the #81867 skill-scaffold split is
  applied only while the skill message is inside the last-N marker window, so request 3 of a
  skill session re-sends the scaffold as a plain string and re-writes it (~20k tokens on call 2
  of skill-invoked sessions in agent.log).
2026-10-06 14:22:56 +05:30
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
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
e5517d2a03 test(agent): drop a no-op capability write and blank-line runs in the reasoning-surface tests
The exhaustion test pins an OpenRouter route, where the promotion gate
returns False before it reads runtime_capabilities, so setting
answer_in_reasoning=False there changed nothing (quality review). Also
collapse the leftover 3-5 blank-line runs to two.
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
f6cb23383d test(agent): trim reasoning-promotion tests to two invariants
Keep two behavioural invariants for the route gate: an untrusted
OpenRouter/DeepSeek route retries past private reasoning to the visible
answer while the local Nemotron parser route still promotes in one call
(one parametrized test), and exhausted recovery never echoes private
reasoning. The capability-helper, constructor-plumbing and fallback-
restore unit tests duplicate what those end-to-end paths already cover.
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
6f503f5787 test(ssl): pin the SSL_CERT_FILE context to its union cache slot
The getattr(bundle_ctx, '_ctx', bundle_ctx).cert_store_stats() hedge
asserted nothing a non-empty certifi bundle would not satisfy either way.
Assert instead that platform_ssl_context() returned the (bundle, union=True)
cached context, which goes red if the platform path ever builds a
replacing (union=False) bundle context.

Fold3 finding 2 (quality Low).
2026-10-06 12:25:01 +05:30
kshitijk4poor
e11be46b84 test(ssl): assert the platform_ssl_context contract in the ssl_verify tests
Gate finding (C02 fold2 #2, quality): the SSL_CERT_FILE contract of
platform_ssl_context (bundle honoured on top of the platform store,
cached once, distinct from the plain platform context, CAs loaded) is an
agent/ssl_verify.py behaviour, but it was asserted inside the Telegram
close-wait test. Move it into the existing SSL_CERT_FILE test in
tests/agent/test_ssl_verify.py (no new test function); the Telegram test
keeps only its every-client-shares-the-context checks.
2026-10-06 12:25:01 +05:30
kshitijk4poor
50b57ffeff test(compaction): trim the redundant fallback-streak gate tests
The rotation-state gate test now pins only that a durable fallback
streak never blocks should_compress (renamed to say so); its
clear-and-reload tail re-tested the loader, not the gate.
test_fallback_streak_alone_does_not_block_compaction duplicated
test_fallback_boundaries_wait_for_real_effectiveness_verdict, which
already asserts should_compress stays open with a streak of 2.
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
164d024bb3 test(compaction): move the bench probe rebuild test into its own file
Code-health ratchet: tests/agent/test_context_compressor.py grew to
3847 > 3829. New tests go in a topical test_<stem>_<topic>.py.
2026-10-06 12:03:52 +05:30
kshitijk4poor
5c9a76a9cd test(compaction): trim bench tests to the two invariants
Keep one test per invariant: effective fallback compactions no longer block
auto-compaction (test_fallback_boundaries_wait_for_real_effectiveness_verdict)
and the bench probe deadline survives a compressor rebuild (which also pins
that a streak >= 2 benches the summary model). Drop the window-arithmetic
tests that pinned the old in-memory clock and the unrelated session-reset
counter test.
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
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
Teknium
c99299d746 feat(telemetry): memory and compression rows say why they failed or were skipped
Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.

- hermes.memory.op.count and hermes.compression.count gain one closed-enum
  dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
  tools/memory_tool_store.py names its class (no_match, ambiguous,
  over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
  missing_content, missing_old_text, invalid_args, stale_entry, disabled,
  staged, gate_refused) through a context variable; the result the model
  sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
  compressor's closed names (exception:<Type> / rollback:<Type> keep only the
  prefix, anything else reads other, a classless failure unknown). Skipped
  rows carry the skip reason, and the skip set is now defined once in the
  contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
  valid so pending rows drain. Doc table rows and the consent copy (CLI
  setup and every Desktop locale's collectedActivity) name the new reason.
2026-10-05 22:07:41 -07:00