Commit graph

1289 commits

Author SHA1 Message Date
wjq990112
78312c192d fix(moa): preserve custom provider context metadata
Preserve compatible custom provider metadata through MoA aggregator context resolution and cover the resolver and compressor paths.
2026-07-23 11:21:04 -07:00
Teknium
4ab3bf66f1 test(moa): cover the one-shot /moa aggregator path for slot extra_body
Follow-up to #60168's salvage: aggregate_moa_context() is the third
independent MoA call path; assert its aggregator call receives the
custom-provider request_overrides.extra_body via **agg_runtime.
2026-07-23 11:20:54 -07:00
panding
1d603fe822 fix(moa): pass custom extra_body to slots 2026-07-23 11:20:54 -07:00
srojk34
2962ba2b7b fix(auxiliary): treat explicit model:auto sentinel, not just cfg_model
'auto' is a sentinel meaning "inherit from main runtime / auto-detect",
not a literal model id -- already handled for cfg_model (config-derived)
in _resolve_task_provider_model, but not for the explicit `model` kwarg.

MoA reference/aggregator slots (agent/moa_loop.py's _slot_runtime) forward
a preset's `model:` field as this explicit argument rather than through
auxiliary.<task> config, so a MoA preset configured with `model: auto`
(a natural thing to try given the existing auxiliary.*.model: auto
convention) reached this function as the explicit `model` arg and took
the `model or cfg_model` branch, bypassing the cfg_model-only sentinel
check entirely -- sending the literal string "auto" to the wire as a
model id.

Normalize both the explicit `model` and `cfg_model` the same way, fixing
this at the single chokepoint every caller (MoA included) already goes
through, rather than patching moa_loop.py separately.
2026-07-23 11:20:43 -07:00
Teknium
d9165d7a67 fix: resolve current entry unlocked in try_refresh_matching no-hint branch
Follow-up to the #62614 salvage: try_refresh_matching (added by the
#69843 salvage after this PR's base) calls self.current() while already
holding the now-locking non-reentrant pool lock — a guaranteed deadlock
that git merges silently (no textual conflict). Use _current_unlocked()
and cover the method in the no-deadlock test.
2026-07-23 09:31:58 -07:00
solyanviktor-star
769381fb3e fix(credential_pool): complete the locking boundary across the public pool surface
Follow-up to review feedback:

- Acquire self._lock in the remaining public pool-state methods:
  has_credentials, reset_statuses, remove_index, resolve_target, and
  add_entry. All of them read or rebind self._entries (and the mutating
  ones persist auth.json), so they now hold the same lock as select()
  and the query methods. None are called from within the lock, so no
  unlocked helpers are needed.
- Make the blocking test deterministic: an instrumented lock records the
  acquire attempt, and the test first waits for the worker to actually
  reach self._lock before asserting it blocks. Previously an unlocked
  method could pass if the worker thread was scheduled late.
- Extend the lock test matrix to all nine public methods; the five newly
  locked ones fail the test without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:31:58 -07:00
solyanviktor-star
5b794c984e fix(credential_pool): acquire the pool lock in has_available/peek/current/entries
`has_available()`, `peek()`, `current()` and `entries()` read (and, via
`_available_entries()`, mutate and persist) `self._entries` without holding
`self._lock`, while every other entry point — `select()`,
`mark_exhausted_and_rotate()`, `acquire_lease()`, `try_refresh_current()` —
guards the exact same access with the lock.

`_available_entries()` is not read-only: it prunes aged-out DEAD manual
entries (rebinding `self._entries` at the prune step) and calls `_persist()`
(writes auth.json). The gateway runs platform adapters in threads and cron
runs jobs in a ThreadPoolExecutor, so a status probe via `has_available()`
or `peek()` can race a concurrent `select()`/rotation: torn iteration of
`self._entries`, interleaved auth.json writes, or a lost token rotation.

Fix: take `self._lock` in all four query methods. Because the lock is
non-reentrant and `peek()` composes `current()` + `_available_entries()`,
add a lock-free `_current_unlocked()` helper and route the already-locked
internal callers (`_select_unlocked`, `mark_exhausted_and_rotate`,
`_try_refresh_current_unlocked`) through it to avoid self-deadlock.

Added regression tests: a no-deadlock check (peek re-entrancy) and a
lock-held-blocks-the-call check for each of the four methods.
2026-07-23 09:31:58 -07:00
Teknium
547bf1ee9e test: isolate unmatched-hint regression from live ~/.claude credentials
Follow-up to the #65844 salvage: the new anthropic pool test must stub
read_claude_code_credentials like the sibling tests, otherwise a dev
machine's live claude_code singleton seeds a third entry and the
no-benching assertion fails outside CI.
2026-07-23 09:22:02 -07:00
Blade
3d67f00fe1 fix(credential-pool): stop lost-update cooldown erasure and wrong-key quarantine
Two related races in credential-pool cooldown state:

1. Lost update across processes: write_credential_pool merged only
   entries missing from the caller's snapshot; for entries present on
   both sides the caller's in-memory copy won wholesale. A process
   holding a snapshot taken before another process marked a key
   exhausted would, on its next persist (e.g. a round-robin rotation),
   write the key back as healthy — erasing the cooldown so every
   process resumes hammering a rate-limited key. Merge status fields by
   last_status_at recency: adopt the on-disk status only when it is
   strictly newer AND still binding (DEAD, or EXHAUSTED with an
   unexpired cooldown), and never onto re-authed (token-changed)
   entries, so legitimate expiry-clears and fresh logins are preserved.

2. Wrong-key quarantine: when mark_exhausted_and_rotate received an
   api_key_hint that matched no entry, it fell through to
   current()/_select_unlocked() — on a freshly loaded pool that selects
   the NEXT healthy key and benches it for the full cooldown TTL,
   punishing an innocent credential. When a hint is provided but
   unmatched, rotate without marking anything instead of guessing.

Includes regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:22:02 -07:00
李航
0e15805e25 fix(credential-pool): exhaust all entries sharing a failed API key on 402
A 402/429/401 is an API-key–level failure (account out of balance,
rate-limited, or key rejected), but the same key can back more than one
pool entry — e.g. an explicit pool entry plus a `model_config` entry
auto-seeded from `model.api_key`, both carrying the identical
`runtime_api_key`.

`mark_exhausted_and_rotate(api_key_hint=...)` only marked the *first*
matching entry, leaving the sibling OK. `_select_unlocked()` then kept
handing back the same depleted key, so the billing-recovery `continue`
loop in the conversation retry path never converged: the request hung
until the client disconnected (~2.5min observed against DeepSeek),
emitting only `response.created` with no 402 ever surfaced to the user.

Mark every entry sharing the failed key so the pool can reach the
"no available entries" state and let the error propagate immediately.

Adds a regression test covering two entries backed by the same key.
2026-07-23 09:08:28 -07:00
Teknium
1d1b670cb5 fix(compression): reset blocked-overflow dedup on every compression path + noise-filter survival pins
Follow-up fixes for the #62625 salvage:

- Dedup-reset gap (sweeper review): when the block clears while the
  context is STILL over threshold, execution enters the compression
  branch — the PR's 'else' reset never ran, so the warning stayed
  suppressed forever after the first block. _clear_context_overflow_warn()
  now fires on every automatic compression path: turn-context preflight,
  conversation_loop pre-API gate, and the post-tool loop-compaction gate.
- should_compress_info on current main: main refactored should_compress
  into _automatic_compression_blocked()/_locally(); the tuple variant now
  derives its reason from the same in-memory state via
  _compression_block_reason(), keeping cooldown:<s>/ineffective shapes.
- ContextEngine.should_compress_info ABC default now actually returns
  (should_compress(tokens), None) — the PR's default had a docstring but
  no return (returned None, would crash tuple-unpacking call sites).
- Below-threshold guard: the turn-context persisted-cooldown branch and
  the conversation_loop pre-API cooldown branch no longer warn when the
  estimate is under threshold (should_compress_info returns a None
  reason; the preflight pre-check is not a threshold guarantee). The
  pre-API guard also honors compression.max_attempts instead of a
  hardcoded 3, and no longer fabricates a cooldown reason.
- Noise-filter survival (#69550 composition): warning text is now a
  template constant (CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE) marked
  FAILURE-CLASS, pinned un-swallowed in VISIBLE_COMPRESSION_MESSAGES and
  in new tests that execute the real _TELEGRAM_NOISY_STATUS_RE +
  _prepare_gateway_status_message.
- Contributor mapping for stanislav@local -> sl4m3.
2026-07-23 08:43:21 -07:00
Stanislav
5c8d098eb3 Address sweeper review: safe should_compress_info + cover all guards
- ContextEngine.should_compress_info() default impl so plugin engines
  (e.g. _StubEngine) don't raise AttributeError at the call site.
- Centralise warning/reset in AIAgent._warn_context_overflow_blocked /
  _clear_context_overflow_warn so turn-context and conversation-loop guards
  share identical dedup logic and reset on the real compression boundary.
- Cover conversation_loop.py pre-API (~L1007) and loop-compaction (~L4774)
  guards, not just the turn-context preflight.
- _FakeAgent mirrors the two helpers; test suite green (219 passed).

Fixes #62708
2026-07-23 08:43:21 -07:00
Stanislav
b0a88899bf Surface warning when context exceeds compression threshold but compression is blocked
Previously, when a session crossed the compression threshold but compression
was skipped (summary-LLM cooldown, #11529, or anti-thrashing, #40803), the
model kept accumulating context until it hit the hard provider token limit and
silently stopped answering — with no signal to the user about why.

Changes:
- context_compressor.should_compress_info() returns a (should_compress, reason)
  tuple. reason is 'cooldown:<seconds>' or 'ineffective' when compression is
  needed but blocked. should_compress() keeps its bool contract so existing
  callers (conversation_loop.py) and regression #29335 are unaffected.
- turn_context.build_turn_context() emits a deduped _emit_warning when the
  context is over threshold but compression is blocked, advising /new or
  /compress. Dedup keys on the block *kind* (cooldown/ineffective), not the
  ticking countdown, so a cooldown doesn't re-fire the warning every turn.
- Adds tests/agent/test_turn_context_overflow_warning.py covering the tuple
  shape, both block kinds, dedup, and re-fire-after-clear.
2026-07-23 08:43:21 -07:00
happy5318
6bd02ae1a6 feat(image_routing): accept vision alias for custom provider models
Extend the existing candidate-name resolver in _supports_vision_override
to accept 'vision' as an alias for 'supports_vision' on per-model config,
for both the providers.<name>.models dict and the legacy list-style
custom_providers form.

Per review feedback on #31912: this extends the current resolver rather
than replacing its candidate-name logic. Named custom providers resolve
to the runtime value provider='custom' while the config keeps the
user-declared name under model.provider; that lookup path is preserved.

Adds regression tests covering model.provider=my-vllm with runtime
provider='custom' for both config shapes.
2026-07-23 08:32:09 -07:00
Teknium
eb7be2edde fix(compress): classify unconfirmed lock-acquire failures and cover all manual-compress surfaces
Follow-up to the salvaged #57634 commits:

- agent/manual_compression_feedback.py: new describe_compression_lock_skip()
  — single source of truth for lock-skip wording. A descriptive holder
  string means another compressor CONFIRMED holds the lock ('already in
  progress (holder: ...)'); True/None means acquisition failed without a
  confirmed holder (hermes_state.try_acquire_compression_lock catches
  sqlite3.Error internally and returns False), so the message says
  'could not acquire ... the lock check failed' instead of falsely
  claiming a concurrent compression is running.
- cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three
  in-process consumers: session.compress RPC, command.dispatch compress
  branch, slash.exec mirror) now route through the shared helper.
- tui_gateway/server.py command.dispatch compress branch: catch
  CompressionLockHeld explicitly — it previously fell into the generic
  'compress failed' error handler.
- Deferred-notify contract (#69324): lock-skip discards the pending
  context-engine notification (committed=False) in _compress_session_history
  and the CLI path before returning.
- tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES
  noise-filter carve-outs for both wordings, MagicMock signal opt-outs for
  sibling tests added on main after the original PR.
2026-07-23 08:19:14 -07:00
Ethan
e8000b42e7 fix: prevent stale lock-skip signal leaking between compress_context calls
Advisor review found a critical stale-signal leak: if auto-compress
sets _compression_skipped_due_to_lock during a lock-skip, a subsequent
successful manual /compress will see the stale signal, falsely report
'Compression already in progress', and discard the compression results.

Fix:
- compress_context clears _compression_skipped_due_to_lock = None at
  entry so each call's outcome alone determines the signal.
- Unified gateway 'holder: unknown' drift to match CLI/TUI pattern
  (omit holder clause when not a descriptive string).
- Added MagicMock opt-outs in 3 sibling test files broken by the new
  signal check (test_compress_here, test_compress_focus,
  test_compress_plugin_engine).
- Added stale-signal-leak invariant test proving the fix.
2026-07-23 08:19:14 -07:00
Teknium
ec5835ab8b
fix(compression): persist anti-thrash state across process restarts (#69872)
The anti-thrash guard (_ineffective_compression_count) was in-memory
only: a fresh compressor bound to a resumed, already-compacted session
started with compression_count=0 and a disarmed guard, so a
near-threshold session could legally re-compact once per process
restart, forever.

Persist the counter through the durable session-state channel,
mirroring the failure-cooldown (#54465) and fallback-streak (af7dceaf7)
pattern:

- hermes_state.py: sessions.compression_ineffective_count column
  (declarative reconciliation adds it on existing DBs) +
  get/set_compression_ineffective_count accessors.
- context_compressor.py: every strike/clear verdict routes through
  _record_ineffective_compression_verdict() which writes through to the
  session row (no-change verdicts skip the DB write);
  bind_session_state() loads the persisted value; the compression
  rotation boundary carries the counter onto the child row;
  update_model()'s reset also clears the durable copy; the
  ineffective-only fast path in _automatic_compression_blocked() is
  removed because the counter is now durable and another agent's clear
  must unblock a stale local snapshot.
- conversation_compression.py: _refresh_persisted_compression_guards
  re-reads the counter alongside cooldown + fallback streak.

Reset semantics are unchanged: any real provider reading below the
threshold still clears the counter — and now clears it durably too.

Resolves the residual gap identified in #54923 by @lanyusea (the
second-threshold mechanism was superseded by persisting the existing
guard state).

Co-authored-by: lanyusea <lanyusea@gmail.com>
2026-07-23 08:08:48 -07:00
Teknium
2755bf558e fix(auxiliary): route all MoA aux resolution through one shared aggregator helper
Follow-up to srojk34's explicit-provider unwrap (PR #56691):

- Extract _resolve_moa_aggregator() as the single preset->aggregator
  resolver shared by _resolve_auto(), _resolve_task_provider_model(),
  and resolve_provider_client() so preset lookup/validation can't drift.
- When the main provider is moa, the aggregator model is now the default
  for every UNSET auxiliary model: _read_main_model_for_aux() substitutes
  the preset's acting (aggregator) model wherever fallback chains
  pre-filled from _read_main_model() (router prefill, custom-endpoint
  fallback, named-custom default, external-process default,
  _try_main_agent_model_fallback).
- Unwrap moa at the resolve_provider_client() chokepoint so direct
  callers (vision auto-detect, plugin code) can't dead-end in the
  unknown-provider branch, and unwrap the vision auto-detect main
  provider before capability probes run against the preset name.
- Real-config tests: temp HERMES_HOME + actual config.yaml exercising
  the genuine load_config()/resolve_moa_preset() boundary.
2026-07-23 07:34:24 -07:00
srojk34
cdfe562342 fix(auxiliary): unwrap explicit provider:moa to its aggregator, not the literal name
_resolve_task_provider_model() returned an explicit provider="moa" override
(from a caller-passed arg, or auxiliary.<task>.provider: moa in config.yaml)
verbatim, with no MoA-preset unwrap. Only the *implicit* "main provider is
moa" path inside _resolve_auto() unwraps to the aggregator slot (#53827) —
this function never goes through _resolve_auto() at all, so the explicit
case was never covered.

MoA is a virtual provider with no real HTTP endpoint: resolve_provider_client()
looks "moa" up in PROVIDER_REGISTRY (no such entry), falls to the
unknown-provider dead end, and call_llm surfaces a nonsensical "Provider
'moa' is set in config.yaml but no API key was found. Set the MOA_API_KEY
environment variable..." error for a provider that was never meant to be
reached over the wire.

Fix mirrors #53827's aggregator-resolution approach exactly: when either the
explicit `provider` arg or the config-derived `cfg_provider` is "moa",
resolve the named (or default) MoA preset via resolve_moa_preset() and
continue with its aggregator's real provider+model, dropping any explicit
base_url/api_key (the moa:// virtual endpoint and placeholder key belong to
the facade, not the aggregator's real provider). If the preset can't be
resolved (renamed/deleted), degrades gracefully to the pre-fix behavior
instead of raising harder.

- agent/auxiliary_client.py: _unwrap_moa_provider() helper + call sites for
  both the explicit-arg and config-derived provider="moa" cases in
  _resolve_task_provider_model(). Also tightened base_url/api_key parameter
  types to Optional[str] (matching their actual None-accepting behavior),
  which incidentally resolved 5 pre-existing ty diagnostics at call sites.
- 5 new regression tests in tests/agent/test_auxiliary_client.py: explicit
  arg unwrap, config-derived unwrap, default-preset fallback when no model
  is configured, graceful degradation on preset-resolution failure, and a
  non-moa regression guard.
2026-07-23 07:34:24 -07:00
schattenan
a9613d2e57 fix(credential-pool): refresh the failing entry, not current(), on auth recovery
Review follow-up: the auth path called pool.try_refresh_current() before
the hinted rotation, so a stale current() pointer could force-refresh a
different, healthy entry — consuming its single-use refresh token, or
(for non-OAuth entries, where a forced refresh marks the entry exhausted
outright) killing it entirely before api_key_hint was ever consulted.

Use try_refresh_matching(api_key_hint=...) to resolve and refresh the
entry that supplied the failing key under the pool lock, falling back to
the previous behavior when no key is known.

Adds a regression test with current() deliberately pointed at the
healthy entry: on the old code the healthy entry is exhausted by the
forced refresh and the pool ends up fully offline; with the fix the
failing entry is exhausted and recovery rotates to the healthy one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:25 -07:00
schattenan
795bf4a9e6 fix(credential-pool): attribute failures to the key that failed, not the shared current() pointer
recover_with_credential_pool identified "which credential failed" via
pool.current(), a shared mutable pointer that is advanced by every
select() (round-robin rotation, concurrent turns, and other processes
reloading the pool reset it to None). By the time recovery ran, it
routinely pointed at a different, healthy entry — mark_exhausted_and_rotate
then stamped the failing request's error message and reset time onto that
innocent entry. With round_robin and one hard-capped key this
deterministically exhausted the healthy key too and took the entire pool
offline ("no available entries") from a single rate-limited credential.

mark_exhausted_and_rotate already supports api_key_hint for exactly this
(the auxiliary-client path passes it); the main conversation-loop path
never did. Pass agent.api_key — kept in sync with the entry in use by
_swap_credential — as the hint on all four rotation call sites, and make
the "already exhausted → rotate immediately" pre-check look up the failing
entry by key with the same fallback to current().

Adds regression tests that fail on the old attribution logic: a fresh
pool (current() is None) failing on key B must mark entry B, never
entry A.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:25 -07:00
Teknium
2e9765b34e fix(gateway): route hygiene-timeout warning via profile-aware adapter lookup + verify lock reacquire after fence cancel
- gateway/run.py: use _adapter_for_source(source) instead of the raw
  adapters.get(source.platform) map so the compression-timeout warning
  respects transport provenance, relay ingress, and multiplexed profiles
  (matches every other user-facing send in the hygiene block).
- tests: add a lock-release verification regression — a fence-cancelled
  hygiene compression must leave the per-session compression lock free so
  the next attempt (manual /compress retry) acquires it and commits
  normally.
2026-07-23 07:26:27 -07:00
kshitij
ca9c30c7f0 fix(gateway): bound hygiene compression failures 2026-07-23 07:26:27 -07:00
Teknium
49a8c61cac fix(context-engine): route pre-API and idle compaction status through the quiet-engine resolver
Follow-up for the salvaged #35191: the mid-turn pre-API pressure emit in
conversation_loop.py and the idle-resume emit in turn_context.py were not
routed through automatic_compaction_status_message, so an engine with
emit_automatic_compaction_status=False still leaked those lines. Both now
resolve through the hook (phases "pre_api" and "idle") while keeping the
#69550 template constants as the default wording. Suppression also skips the
#69546 structured 'compacted' terminal edge for compress-phase events that
opened no visible phase; failure warnings (_emit_warning) remain never
suppressible, pinned by test.
2026-07-23 07:26:15 -07:00
Teknium
2ca38e5df4 test(compression): pin scaffolding-tail standalone append + stale-snapshot refresh
Covers the follow-up hardening: continuation-marker and summary-as-user
tails keep the flagged standalone snapshot (zero-user provenance #69292
verified via _transcript_has_real_user_turn on the projected rows),
stale snapshot rows are refreshed in place, a previously merged snapshot
is stripped before re-injection, and an all-completed todo store injects
nothing (#26981).
2026-07-23 07:25:44 -07:00
Yingliang Zhang
33fd705421 fix(compression): preserve multimodal todo tails 2026-07-23 07:25:44 -07:00
Yingliang Zhang
d2bb6cc251 fix(compression): merge todo snapshot into trailing user msg to avoid consecutive user/user turns
After context compression, the preserved todo list was unconditionally
appended as a standalone user message. When the compressed transcript
already ends with a user message (common case), this creates consecutive
user/user turns — a role-alternation violation some providers reject.

Fix: fold the snapshot into the trailing user message (blank-line separated)
when one exists with plain-string content. Falls back to append when the
tail is non-user, empty, or has structured (list) content.

Rebased on current upstream/main.

Closes #53890
2026-07-23 07:25:44 -07:00
Teknium
18d83b4da9 test(agent): pin the #61932 all-oversized-tail dead-end shape as compressible
Regression test for the exact issue #61932 report: head + an 8-message
protected tail made exclusively of oversized tool pairs.  Pre-fix,
compress_start >= compress_end made compress() a pure no-op and the
retry loop ended in 'Cannot compress further'; post-fix the Phase-1
pressure demotion reclaims the tail in one pass while preserving
tool_call/tool_result pairing.
2026-07-23 07:25:33 -07:00
giggling-ginger
d12ea20009 test(agent): cover protected-tail last resort 2026-07-23 07:25:33 -07:00
giggling-ginger
fe2ae40983 fix(agent): demote oversized tool results in protected compression tail
After multiple in-place compactions, short tool-heavy sessions can leave
nearly every remaining message inside protect_last_n while those messages
are huge completed file/tool outputs. The middle compress window then
makes no material token progress and the turn dies with
"Cannot compress further" (#61932).

Cap the prune message floor at the same bound as tail-cut, and under
pressure demote bulky protected-tail tool bodies (keeping a short recent
floor) so preflight can reclaim headroom without wiping the active ask.
2026-07-23 07:25:33 -07:00
Teknium
929c952596 fix(agent): wire should_compress_preflight into the turn-start preflight flow
Relocates the #20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, #69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.

Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
  ContextEngine.should_compress_preflight() -> False, so the default
  path performs no compression and touches no turn bookkeeping
  (pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
  mutually exclusive with the cap-bounded threshold multi-pass loop,
  so turn-start passes stay within the resolved
  compression.max_attempts budget in every case.
- No-op blocking (#64382 / 377244f7c): an engine pass that no-ops
  (_compress_context returns the input list object) neither sets nor
  clears preflight_compression_blocked and does not re-baseline the
  flush history — a sub-threshold maintenance no-op proves nothing
  about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
  codex-native gates run before the hook is ever consulted.

Salvaged from #20424 by @Beandon13. Fixes #20316.
2026-07-23 07:25:08 -07:00
wz-heng
91546b8337 fix: preserve named custom provider vision overrides 2026-07-23 17:57:33 +05:30
Fangliquan
9220c0c0bb fix(agent): close tool-result tails on invalid-tool and truncated-tool early returns
Invalid-tool exhaustion and truncated-tool early returns skipped finalize_turn, leaving role=tool transcripts that become tool→user on the next turn for strict providers. Call close_interrupted_tool_sequence before persist on those paths (same as interrupt aborts).
2026-07-23 17:38:05 +05:30
Brooklyn Nicholson
59a735b8f3 feat(credits): report $used of $cap instead of % in the usage notice
The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).
2026-07-23 01:28:58 -05:00
Gille
b3ff5fc5b1 fix(vision): resolve namespaced custom provider overrides 2026-07-22 21:18:45 -07:00
Teknium
ad8c06047d test: cover api_key_hint in strict pool doubles + real-pool routing regression
Follow-up to the #43755 salvage:
- Update the strict _Pool doubles in tests/run_agent/test_run_agent.py to
  accept api_key_hint and assert it carries the agent's failed key.
- Add a real-CredentialPool regression (no mocks) proving the hint routes
  exhaustion to the entry whose key actually failed, not pool.current(),
  plus the no-hint baseline (#43747 wrong-entry marking).
2026-07-22 20:54:29 -07:00
liuhao1024
702f5f10ad fix(agent): pass api_key_hint to mark_exhausted_and_rotate in credential pool recovery
recover_with_credential_pool() called mark_exhausted_and_rotate() without
api_key_hint, causing it to fall back to current() or _select_unlocked().
When a prior rotation left current() as None, _select_unlocked() returned
the NEXT (healthy) entry instead of the one that actually failed — marking
the wrong credential as exhausted (#43747).

Extract the current API key from agent.api_key (or pool.current().runtime_api_key
as fallback) and pass it as api_key_hint to all 4 call sites.
2026-07-22 20:54:29 -07:00
brooklyn!
2c1d585002
Merge pull request #69655 from NousResearch/bb/out-of-credits-ux
feat(billing): consistent out-of-credits UX across CLI, TUI, and desktop
2026-07-22 21:03:38 -05:00
Teknium
50026fbaa1 test(compression): pin classify_summary_content agreement with summary predicates on live emissions
Hardening for the #59114 salvage: generate real standalone and merged
handoffs with the current compressor and assert classify_summary_content
agrees with _is_context_summary_content and is_compaction_summary_message
on every emitted shape (behavior contract, not a format snapshot), incl.
the flag-stripped DB-reload copy. Also adds the contributor email mapping
for @israellot.
2026-07-22 16:58:35 -07:00
Israel Lot
24e4c6fbf6 fix(acp): flag replayed compaction summaries via _meta
A context-compaction handoff is persisted as an ordinary history message
but is not a real turn. The ACP history replay streamed it as a bare
user/agent message chunk, dropping the in-process _compressed_summary
marker, so ACP frontends (editors, vscode-hermes) rendered the entire
handoff as a regular message.

Tag replayed summary chunks under _meta.hermes (ACP's extensibility
channel), covering all three persistence shapes the compressor emits:

- standalone role="user" handoff -> compactionSummary: true
- standalone role="assistant" handoff (alternation-driven role pick)
  -> compactionSummary: true
- merge-into-tail message (preserved tail content + appended summary)
  -> containsCompactionSummary: true, a distinct key so clients that
  collapse standalone summaries cannot hide the preserved real content

Detection honors the in-process metadata flag and falls back to a new
ContextCompressor.classify_summary_content() content classifier
(standalone/merged/None), so it also works for a DB-reloaded session
that lost the in-memory flag. _is_context_summary_content is now a thin
wrapper over the classifier, keeping existing callers unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:58:35 -07:00
Brooklyn Nicholson
960d339f86 feat(billing): shared cross-surface out-of-credits signal
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and
map it to a recovery link + label in one place, then carry that structured
BillingBlock to every surface instead of re-parsing free-form error text per
surface.

- agent/billing_links.py: provider-agnostic slug/host → (label, billing URL)
  table (single source of truth), Nous-aware (is_nous routes to the in-app flow);
  unknown providers degrade to a readable label with no invented URL.
- conversation_loop: both billing exit paths return a billing_block through one
  helper; the guidance message carries the derived URL for every provider.
- gateway forwards billing_block on message.complete (it was dropped).
- @hermes/shared: BillingBlock type shared by desktop + TUI.
2026-07-22 18:08:59 -05:00
ethernet
d84e11af4d
rip out brew + pip/PyPI wheel support (#68217)
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.

Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
  (MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
  and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
  builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
  self-update path, the deprecation-banner state, the postinstall subcommand,
  wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
  and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
  installs (which don't set HERMES_MANAGED) are correctly identified as
  "nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
  .install_method stamps (both code-scoped and home-scoped) are ignored by
  the allowlist reader and fall through to "unknown" instead of resurrecting
  a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
  through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
  tests; adds parametrized coverage for the packaging build guard covering
  BOTH sdist and wheel paths (the guards live in separate cmdclass entries
  — a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
  a finding, while additions or modifications still require the existing
  ci-reviewed label gate.

Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
2026-07-22 16:51:01 -04:00
Brooklyn Nicholson
f6d2ee4afc feat(codex): honor redirect and hard stop in the app-server runtime
The Codex app-server runtime bypasses the main conversation loop and drives
its own subprocess turn, so it needs first-class hooks rather than the
OpenAI-loop interrupt path.

- `AIAgent.interrupt()` now forwards a hard stop to
  `CodexAppServerSession.request_interrupt()`, and `redirect()` uses Codex's
  native `turn/steer` protocol instead of cancelling the subprocess.
- `run_turn()` no longer clears an interrupt that arrived during
  `ensure_started()`: a stop landing mid-startup is honored before `turn/start`,
  and the interrupt event is cleared on every exit path.
- `run_codex_app_server_turn()` mirrors the loop finalizer's interrupt handoff
  (surface `interrupted` / `interrupt_message`, then `clear_interrupt()`) on
  both the normal and exception early-return paths, so a hard stop can't leave
  `_interrupt_requested` stale for the next turn.
2026-07-22 12:02:40 -05:00
Brooklyn Nicholson
cbf5b05c70 feat(agent): add active-turn redirect core primitive
A follow-up sent while the model is still generating previously ended the
turn: Hermes kept only the visible partial text (reasoning was display-only),
cleared the loop, and replayed the message as a fresh next turn. If the
correction referred to something that only appeared in the thinking stream,
the model no longer had that context.

Add `AIAgent.redirect(text)`: a corrective interrupt distinct from a hard
stop. It cancels only the in-flight model request (not tool workers or child
agents), stashes the correction under a lock shared with `interrupt()` so a
concurrent `/stop` always wins, and lets the loop rebuild the same logical
iteration. `_apply_active_turn_redirect()` checkpoints the reasoning that was
actually shown to the user plus any visible partial text as an ordinary
assistant message, then appends the correction as a real user turn — never
replaying incomplete signed/encrypted provider reasoning, and keeping strict
role alternation and prompt-cache stability intact. During tool execution it
degrades to `steer()` so a running tool finishes at a safe boundary.

`_fire_reasoning_delta` now only records reasoning that a display callback
actually consumed, so `show_reasoning: false` never leaks hidden provider
thinking into the persisted transcript.
2026-07-22 12:02:40 -05:00
Teknium
1efe7094ad feat(compression): harden idle compaction — lock/guard interplay, config + docs
Follow-up for the salvaged #55800 idle-compaction commit:

- turn_context.py: treat a skipped _compress_context (per-session
  compression lock held by another path, failure cooldown, anti-thrash
  breaker, codex-native routing) as a strict no-op — only re-baseline
  conversation_history and re-anchor current_turn_user_idx after a REAL
  compaction. Also re-anchor the user-message index after idle compaction
  (the PR predates the reanchor helper).
- hermes_cli/config.py: add idle_compact_after_seconds: 0 to
  DEFAULT_CONFIG's compression block (the PR only had
  cli-config.yaml.example).
- gateway/run.py: add the idle-compaction status wording to
  _TELEGRAM_NOISY_STATUS_RE so the new 💤 message stays out of
  human-facing chat surfaces (routine compaction is silent by design);
  pin it in tests/gateway/test_telegram_noise_filter.py.
- docs: idle_compact_after_seconds in user-guide/configuration.md and
  developer-guide/context-compression-and-caching.md parameter table.
- tests/agent/test_idle_compaction_lock_and_guards.py: end-to-end
  coverage with a real AIAgent + SessionDB proving the idle path honors
  the per-session compression lock (added after the PR), the persisted
  failure cooldown, and the anti-thrash breaker, and that the lock is
  released after an idle-triggered compaction.

Salvaged from #55800 by @iso2kx. Implements #27579.
2026-07-22 09:14:11 -07:00
Muthuvel
72056faf8f feat(compression): add opt-in idle-triggered context compaction
Long-lived sessions (e.g. a Telegram thread resumed over hours/days)
accumulate a large context that the existing size-based threshold only
trims once it crosses `threshold × context_window`. Until then every
turn re-reads the full history, which on large-context models can mean
hundreds of K of cache-read tokens per call even across long idle gaps.

Add a time-based trigger that complements (does not replace) the size
threshold: when a session resumes after `compression.idle_compact_after_seconds`
of inactivity, compact the accumulated history up front, before the first
reply. Disabled by default (0), so existing behaviour is unchanged.

The trigger reuses `_last_activity_ts` (the last time the turn loop did
work) to measure the idle gap at turn start, gates the token estimate
behind a cheap gap pre-check, and skips compaction when the context is
already at/below the post-compression target (threshold × target_ratio)
so a short idle thread never pays for a summarization that saves nothing.
It also defers to an active compression-failure cooldown.

The decision is factored into a pure predicate, `_should_idle_compact`,
which is unit-tested without a live agent.
2026-07-22 09:14:11 -07:00
Teknium
76e17bc32d fix(compression): recover merged-handoff prior-tail content through the decay scan
Composing #57835's multi-fossil summary scan with #47274's merged-handoff
unwrap: when the restart-decay path pulls a merged handoff into the
compression window, its genuine prior-tail user content must enter the
summarizer input (folded into the fresh summary) rather than being
dropped with the summary row. Standalone handoffs still drop. The
continuity test now pins the composed contract: recovered verbatim OR
via summarizer input, never silently deleted, never duplicated.
2026-07-22 09:10:49 -07:00
Teknium
a4aedde3ae test(compression): adapt salvaged pins to task-snapshot grounding + add restart-simulation test
- Three '_previous_summary == "fresh summary"' exact pins and one
  transcript-wide fossil-absence pin predated main's deterministic
  task-snapshot grounding (761a0b124e), which prepends a
  '## Historical Task Snapshot' section to stored summaries and may
  quote a folded head turn inside the handoff. Re-pin the contracts
  (fresh body present, fossil absent from non-summary messages)
  instead of exact strings.
- Add test_restart_simulation_fresh_compressor_does_not_reprotect_head:
  a fresh ContextCompressor over a transcript containing a persisted
  handoff summary computes the same decayed protected-head boundary
  (compress_start base) as a live already-compacted process, and the
  first post-restart compaction does not preserve pre-restart head
  fossils (#57814).
2026-07-22 09:10:49 -07:00
harjoth
08ea88f4f7 fix(agent): decay protected summaries after restart
protect_first_n decay state (compression_count / _previous_summary) is
in-memory only, so a gateway restart re-protected the persisted handoff
summary and head fossils, growing the head unboundedly across
restart+compaction cycles (#57814).

_effective_protect_first_n now probes a bounded resumed-head window for
a persisted handoff summary (by metadata or content prefix) and decays
protection when one is found, before compress_start is computed. The
first post-restart compaction self-heals: stacked summary fossils are
folded into the next summary prompt instead of preserved verbatim,
rehydration is rolled back on abort, deterministic fallback carries a
bounded redacted previous-summary snapshot, and forced user-leading
merged summaries keep the live tail request after the summary end
marker so they stay rehydratable.

Squashed reapply of the 12-commit series from PR #57835 onto current
main (branch was 1210 commits behind; single add/add conflict with the
task-snapshot grounding helpers resolved by keeping both).

Fixes #57814.
2026-07-22 09:10:49 -07:00
Teknium
cbc1054e23 fix: adapt compression attempt logging to current main aux-call contract
- aux summary call on main intentionally omits max_tokens; use .get() in the
  telemetry hook (and widen the param type) so the hook never breaks the call
- update test expectation: aux_output_reservation is None on main
- record no_progress failure_class in the no-progress boundary branch

Follow-up for salvaged PR #60444.
2026-07-22 08:13:41 -07:00