Commit graph

1227 commits

Author SHA1 Message Date
Tanmay Dixit
2eb6c84bbb fix(codex): wait for compaction turn identity 2026-07-22 06:57:44 -07:00
Tanmay Dixit
63e363f306 fix(codex): scope app-server notifications to active turn 2026-07-22 06:57:44 -07:00
Teknium
8fceaac143 test(compression): adapt strict-signature heartbeat test to signature-inspection dispatch
Main no longer catches TypeError to fall back — _supported_compression_kwargs
inspects the engine signature and calls once with only accepted kwargs.
Rework the salvaged test to assert a single correctly-shaped call while
keeping heartbeat start/stop and lock-release coverage.
2026-07-22 06:57:33 -07:00
WeiYusc
928bcdde24 fix(compression): refresh gateway activity during compaction
Refresh the agent activity tracker while context compression is blocked in the auxiliary summarizer so gateway watchdogs do not report inactivity during long compactions.

Add regression coverage for successful heartbeats, exception cleanup, touch failures, and strict-signature compressor fallback.

(cherry picked from commit c09e58b7709cc60c5b454701f0ecf840e759222f)
2026-07-22 06:57:33 -07:00
Teknium
ea0fd393db perf(compression): gate CJK-aware token estimation behind an ASCII fast path
The salvaged estimator ran a per-character Python loop on every
estimate_tokens_rough() call — a ~28,000,000x slowdown vs (len+3)//4 on a
1MB ASCII tool output (measured ~3.0s per call). Gate it:

- str.isascii() O(1) fast path keeps pure-ASCII text bit-identical to the
  classic (len+3)//4 rule at ~1.3x baseline cost (0.23us vs 0.17us per
  1MB call).
- Non-ASCII text counts dense CJK chars via a compiled character-class
  regex in C (len(text) - len(re.sub(''))): ~352ms/1MB hangul vs ~2.1s
  for the per-char loop.
- Non-ASCII-but-non-CJK text (accents, Cyrillic, emoji) keeps the classic
  rule.

Also: parity tests against the per-char reference implementation, and
updated two stale expectations that encoded the old behavior (CJK now
counted ~1 token/char; short string content now ceil-divided instead of
floored to 0). The continuity test now detects merged-into-tail summaries
via _is_context_summary_content.
2026-07-22 06:57:22 -07:00
miniadmin
3f33a1c5aa fix(compression): handle CJK token budgeting 2026-07-22 06:57:22 -07:00
embwl0x
7de7a073c6 fix(compression): preserve synthetic user provenance 2026-07-22 06:56:53 -07:00
embwl0x
c93e4041b6 fix(compression): preserve zero-user provenance 2026-07-22 06:56:53 -07:00
Teknium
1c2faedd88 fix(compression): unify the attempt cap across every compression site
Follow-up to the salvaged #64010 (Kenmege) and #63870 (dombejar) commits,
making one resolved compression.max_attempts cap govern ALL per-turn
compression attempt sites:

- conversation_loop: resolve max_compression_attempts ONCE at turn start
  (it was previously re-resolved inside the API-call loop) and route the
  pre-API pressure gate through it — that gate still hardcoded
  'compression_attempts < 3' and logged 'attempt=%s/3'.
- conversation_loop: the salvaged post-tool compaction gate now uses the
  resolved cap instead of a hardcoded 3.
- turn_context: the preflight compaction loop was 'for _pass in range(3)';
  it now sizes itself from the same resolved cap.
- agent_init: harden the max_attempts parser — reject booleans (bool
  subclasses int; 'true' would coerce to 1), reject fractional floats
  instead of truncating them, keep accepting integral floats and numeric
  strings; anything else falls back to 3 (floor 1, ceiling 10 unchanged).
- tests: replace #63870's inspect.getsource source-shape test with
  behavioral loop tests (post-tool compaction fires <= cap times per turn,
  shares its budget with the pre-API gate, resets between turns); add an
  e2e test proving a 4th preflight pass runs at config cap=6 while the
  unset default still stops at 3; extend the #64010 config tests with the
  bool/float parser semantics.

Salvages #64010 by @Kenmege and #63870 by @dombejar.
2026-07-22 06:56:42 -07:00
Kennedy Umege
644b397fb2 fix(agent): make the compression retry cap config-driven (compression.max_attempts)
The conversation loop hardcodes max_compression_attempts = 3. Sessions
that legitimately need more rounds are stranded: on a restart history
reload, incompressible tool schemas can keep the per-request estimate
above the compressor threshold even though the message floor compresses
correctly, so three rounds cannot clear it and the turn dies with
"Context length exceeded: max compression attempts (3) reached" — the
same failure class as #62605, where the rough estimate similarly leaves
3 retries short.

Make the cap a config key, compression.max_attempts:

- default 3 = identical to today, so an unset key is behavior-neutral;
- parsed and validated in agent_init alongside the other compression.*
  keys (>= 1, hard-capped at 10, non-integer values fall back to 3),
  attached as agent.max_compression_attempts;
- the loop reads it via getattr(agent, "max_compression_attempts", 3),
  so objects without the attribute keep the prior behavior;
- documented in the DEFAULT_CONFIG compression block.

Tests pin the parse/validate/attach seam: default preserved, custom
value honored, floor and ceiling enforced, garbage tolerated, and the
loop-side getattr degradation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 06:56:42 -07:00
izumi0uu
0583692c2d fix(secrets): scope BWS-injected provider keys
Snapshot values applied by external secret sources per resolved HERMES_HOME so a later profile cannot replace an earlier profile scope through shared os.environ.

Keep provider and credential-pool fallback reads on the active secret scope, and fail closed on unscoped multiplex reads.

Tests: scripts/run_tests.sh tests/test_env_loader_secret_sources.py tests/test_env_loader_op_bootstrap.py tests/agent/test_secret_scope.py tests/agent/test_credential_pool.py tests/tools/test_credential_pool_env_fallback.py tests/hermes_cli/test_xiaomi_provider.py tests/cron/test_run_one_job.py tests/hermes_cli/test_api_key_providers.py tests/gateway/test_multiplex_credential_isolation.py -q (395 passed)
2026-07-22 04:39:17 -07:00
Soju06
c758ded6d2 fix(secrets): fall back to os.environ on scope miss when multiplexing is off
fdab380a1 wraps every cron job in a <home>/.env secret scope regardless of
deployment mode. get_secret() treats any installed scope as authoritative,
so in single-profile deployments where provider keys live only in the
process environment (systemd Environment=, pass-cli/op run wrappers, shell
exports) every cron credential read returns empty, the OpenAI client is
built with the no-key-required placeholder, and each scheduled job 401s —
while interactive turns keep working. Scope-miss reads now fall through to
os.environ when multiplexing is off; multiplexed scopes stay authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:20:35 -07:00
McHermes
f4896015c1 fix(compression): ignore assistant handoff summaries in tail anchor
Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize.

Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.
2026-07-22 11:46:05 +05:30
cucurigoo
377244f7c8 fix(compression): prevent stale-budget retry loops 2026-07-22 11:19:37 +05:30
kshitijk4poor
a46fbafe31 test: drop source-grep change-detector from #68480
The three behavior tests (control proves dup, boundary is noop, tail-only
write) fully cover the flush semantics. The source-grep test reading
cli.py + cli_commands_mixin.py as text and asserting a string appears is
a change-detector that breaks on benign refactors without adding coverage.
2026-07-22 10:00:39 +05:30
Bartok9
1c0a57832b fix(cli): pass conversation_history on /new /resume /branch flush
Closes #68454

Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a
normal turn flush stamps them. Immediate /new,/resume,/branch flushed with
no history boundary, so every restored row was re-appended to the old session.

Fix: pass conversation_history=self.conversation_history at all three sites
(mirrors #68205). Add offline regression coverage for noop + tail-only write.

Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed)
2026-07-22 10:00:39 +05:30
Siddharth Balyan
9baad4e0aa
feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)
* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split

Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:

- /subscription on Free (admin/owner, interactive) prints the plan catalog
  (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
  credits render as dollars). A numbered pick opens the manage-subscription
  deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
  tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
  The paid change flow's blocked/unknown-preview portal fallback carries plan=
  for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
  stated in each first sentence ("Add funds now — a single charge…" vs "Refill
  when low — charges … automatically …"), keeping "credits" out of the
  dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.

Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.

* refactor(billing): share plan-catalog helpers + harden manage-url builder

- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
  popping only the contract-owned org_id/plan) and restricts to http/https schemes,
  matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
  catalog and the paid picker/blocked-preview branch share one implementation:
  selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
  · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
  hidden when absent/zero), and is_upgrade(state, tier_id).

* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy

- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
  confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
  Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
  device-code flows' console-browser / remote-session guard (webbrowser.open returns
  True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
  Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
  both amounts are present and finite; otherwise the generic sentence.

* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)

Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.
2026-07-22 08:11:09 +05:30
Brooklyn Nicholson
625687f334 feat(status-bar): add /battery toggle for a color-coded battery read-out
Add an opt-in battery indicator to the CLI and TUI status bars, shown as
the first element and colour-coded by charge (green/yellow/orange/red, or
green while charging). Off by default and a no-op on machines without a
battery.

- agent/battery.py: shared psutil-backed reader with a short TTL cache,
  category bucketing, and a compact 🔋/ label. Fails open to
  "unavailable" everywhere.
- CLI: /battery [on|off|status] toggle persisted to display.battery,
  rendered first in every status-bar width tier.
- TUI: /battery slash command, config sync, a system.battery RPC polled
  while enabled, and a pinned first segment in StatusRule.
2026-07-21 13:54:11 -05:00
sbe27
0c0ec18d6f test(context): cover Codex context rollback 2026-07-21 04:29:34 -07:00
sbe27
60afc290a8 fix(context): scope Codex catalogue cache by credential 2026-07-21 04:29:34 -07:00
sbe27
9a34cc91a5 test(context): document Codex cache persistence coverage 2026-07-21 04:29:34 -07:00
sbe27
8a0701ca48 fix(context): revalidate Codex OAuth context windows 2026-07-21 04:29:34 -07:00
Siddharth Balyan
b0da653ac8
fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
2026-07-21 12:20:25 +05:30
PRATHAMESH75
0ba889d492 fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196)
The legacy rotation branch in agent/conversation_compression.py flushes the
current turn to the OLD session before ending it (#47202) via
_flush_messages_to_session_db(messages) with no conversation_history boundary.

On the first turn after a cold Desktop resume, the restored transcript rows
live in the message list as plain dicts that have not yet been stamped with
_DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after
preflight compression. With no boundary, _flush_messages_to_session_db builds
an empty history_ids set and treats every restored row as new, durably
re-appending the whole transcript to the parent session. Repeated
restart/resume + threshold compression keeps growing the parent transcript.

Pass messages[:_persist_user_message_idx] (the already-durable prefix that
turn_context anchors before preflight runs, guarded for int/bounds) as
conversation_history so the flush skips the persisted rows by identity and
writes only the current turn's new messages.

Adds a regression test that pre-populates SQLite, cold-loads the transcript,
appends one current user row, and forces rotating compression: it fails before
this change (parent grows to 5 rows) and passes after (parent holds the two
originals plus the single new turn).
2026-07-21 12:11:31 +05:30
Gille
d7b36070ef
fix(checkpoints): honor gateway config and task cwd (#68195)
* fix(gateway): wire checkpoint config into agents

* fix(checkpoints): resolve gateway file paths by task cwd
2026-07-20 13:04:12 -07:00
ethernet
18ca0e862c
Merge pull request #66471 from NousResearch/ethie/typescript-lsp
fix(lsp): never report stale diagnostics — version-gated freshness for slow servers
2026-07-20 14:16:50 -04:00
Teknium
086a56a028 fix(matrix): correct platform hint over-claims, add regression tests + docs
Follow-up to the salvaged #52552 and #53083 commits:

- Rework the Matrix PLATFORM_HINTS entry around what the adapter actually
  emits: headings, numbered lists, blockquotes, strikethrough-free markdown
  all render (the adapter converts them to sanctioned HTML). Keep the
  genuinely valuable guidance: no Markdown tables (Element X / Beeper /
  mobile clients don't render HTML tables — cells collapse into one line),
  no spoilers/checkboxes/~~strikethrough~~ (not converted by
  python-markdown), prefer descriptive link text.
- Regression test: hint must steer models away from tables.
- Fix test_long_response_split_preserves_thread_context to derive its
  payload size from the adapter's configurable limit instead of assuming
  the old hardcoded 4000.
- Document matrix.max_message_length in the Matrix docs page.
- Contributor mapping for RKelln.
2026-07-20 11:10:49 -07:00
Teknium
a5b9803a51 test(transports): assert Moonshot wire schema carries required:[]
Transport-level regression for #66835 — verifies the outgoing tool
schema at the chat_completions build_kwargs boundary, not just the
sanitizer unit.
2026-07-20 11:05:52 -07:00
PRATHAMESH75
acc1b6e76a fix(agent): inject empty required array on Moonshot object schemas
Moonshot/Kimi's tool-parameter validator rejects object schemas that omit
the required key with HTTP 400 ("required must be an array"), even though
standard JSON Schema allows omitting it. Any Hermes tool with zero required
parameters (browser_back, delegate_task, project_list, several MCP list_*
tools, etc.) tripped this when routed to a Moonshot endpoint.

Add a Rule 4 to the sanitizer: every object schema gets a required array,
defaulting to []. Existing lists are preserved but pruned to names that
actually appear in properties (dangling entries are also rejected upstream).
Applied recursively to nested object schemas and to the coerced/empty
top-level fallback.

Fixes #66835
2026-07-20 11:05:52 -07:00
ethernet
eb09df6ec8 refactor(lsp): version-tagged _DocState replaces timestamp freshness tracking
The staleness fix (f9b1fd799) bolted two wall-clock dicts (_changed_at,
_pulled_at) onto a client that already scattered per-document state
across six parallel dicts (_files, _push_diagnostics, _pull_diagnostics,
_published, _published_version, _first_push_seen) — eight maps kept in
sync by hand.

Collapse all of it into one _DocState per path, and use the LSP document
version as the freshness token instead of clocks:

- didChange bumps doc.version; stored push/pull results carry the
  version they describe (push_version from the server's echoed version,
  or the current version at receipt for servers that don't echo one;
  pull_version captured at request send so an in-flight pull that a
  didChange races past is stale on arrival).
- fresh == tag >= version. Invalidation is implicit in the bump — no
  store-clearing, no clock comparisons, no race windows.
- _has_fresh_push/_has_fresh_pull helpers dissolve into two one-line
  _DocState methods; diagnostics_for(fresh_only=True) becomes a
  three-liner.

Semantics are unchanged from f9b1fd799 (same tests pass, one test
updated off private internals); net -15 lines.
2026-07-20 13:43:36 -04:00
ethernet
a632e68a01 fix(lsp): never report stale diagnostics — wait for fresh post-edit data
Slow language servers (tsserver on large projects especially) publish
diagnostics long after an edit. The client's wait/report path had three
holes that together surfaced the PREVIOUS edit's errors as if they were
current ("ghost diagnostics"), sending the agent chasing errors it had
already fixed:

1. open_file only cleared the diagnostic stores on first open — on the
   didChange path (every subsequent edit) stale push/pull entries
   survived.
2. wait_for_diagnostics' predicates were satisfiable by that leftover
   state (`path in _published`, `path in _pull_diagnostics`), so the
   "wait" often returned instantly with old data.
3. diagnostics_for merged the stale push store unconditionally, so even
   a fresh clean pull got the old error merged back in.

Fix: anchor freshness on a per-file didChange timestamp.

- Pull results record their request send-time and are dropped when a
  didChange raced past them; the pull store is invalidated on every
  change, not just first open.
- wait_for_diagnostics now returns bool (fresh data vs timeout), only
  counts pushes published at/after the change (and version >= ours when
  the server echoes versions), and accepts an explicit timeout — the
  user's lsp.wait_timeout config now actually controls the inner wait
  budget instead of only the outer thread-join.
- diagnostics_for(fresh_only=True) excludes stores that predate the
  latest change; all manager report paths use it.
- On timeout the manager returns [] ("no data") instead of stale
  state, logs a WARNING via eventlog, and does NOT mark the server
  broken — slow is not dead.
- seed-on-first-push no longer marks the file published, so the TS
  seed push can't satisfy a waiter.

Tests: new "stale" and "slow_push" mock-server scripts model the slow
tsserver, plus client- and service-level regression tests
(tests/agent/lsp/test_stale_diagnostics.py).
2026-07-20 13:43:36 -04:00
Teknium
5e999b98cb test: fix stale k3 fixture in deepseek signed-thinking replay test
test_deepseek_still_strips_signed_thinking passed model='k3' with the
DeepSeek base URL — that only held because bare 'k3' wasn't classified
as Kimi family yet. With k3 now correctly classified, the kimi-family
model-name path (deliberate: proxied endpoints preserve thinking,
#13848/#17057) keeps the blocks. Use a real DeepSeek slug for the
DeepSeek behavior, and add an explicit invariant test that Kimi-family
slugs (named and bare) keep thinking on foreign gateway hostnames.
2026-07-20 08:47:55 -07:00
Teknium
25eafd7d71 fix(models): complete kimi-k3 rollout across Kimi-direct catalog surfaces
Follow-up widening for salvaged PRs #67115, #67685, #67620:

- _PROVIDER_MODELS: add kimi-k3 atop kimi-coding / moonshot / opencode-go
  curated lists (kimi-coding-cn covered by cherry-picked #67620)
- setup.py _DEFAULT_PROVIDER_MODELS: kimi-k3 for kimi-coding(-cn) + opencode-go
- model_metadata: align DEFAULT_CONTEXT_LENGTHS kimi-k3 entry to 1,048,576
  (matches endpoint-scoped override, models.dev, and OpenRouter live metadata)
- anthropic_adapter: classify the bare Coding Plan slug 'k3' (and k3.x/k3-*)
  as Kimi family so adaptive thinking applies on proxied endpoints
- moonshot_schema: is_moonshot_model matches bare 'k3' so tool-schema
  sanitization runs on the chat-completions path
- contributor mappings for githubespresso407, datachainsystems, Punyko8

Tests: 582 passed across 11 targeted files; hermetic E2E verifies picker
order (kimi-k3 first), no dupes, and 1M context resolution.
2026-07-20 08:47:55 -07:00
datachainsystems
54c39c0301 fix: add Kimi K3 1M context window to DEFAULT_CONTEXT_LENGTHS
Kimi K3 ships with a 1M-token context window (verified against
platform.kimi.ai/docs/overview) but was falling through to the generic
'kimi': 262144 catch-all. Added 'kimi-k3': 1_000_000 before the catch-all
so longest-key-first substring matching resolves K3 to 1M while older
Kimi models still hit the 256K default.

Added matching test_kimi_k3_context_1m test covering native,
vendor-prefixed (kimi/, moonshotai/), and older model fallback.
2026-07-20 08:47:55 -07:00
githubespresso407
77aa026ca6 Resolve kimi-k3 context length to 1M on canonical Kimi Coding endpoints
Kimi Coding serves K3 under the bare slug 'k3', but users can also
configure or select the public-facing aliases 'kimi-k3' and
'kimi-k3-cot'. The endpoint-scoped 1M context window was only keyed
on the bare 'k3' slug, so selecting 'kimi-k3' fell through to the
generic 'kimi' catch-all (262k).

Extend the guard in _endpoint_scoped_context_length to also recognize
'kimi-k3' and 'kimi-k3-cot', while keeping the endpoint check that
limits the 1M value to https://api.kimi.com/coding (legacy Moonshot
endpoints still fall back to 262k). Update the existing test to cover
all three aliases.

Fixes: context window limited to 262k when using kimi-k3 via kimi-coding.
2026-07-20 08:47:55 -07:00
ethernet
c363db81e0
fix(desktop, ink): don't wipe messages before final message (#65919)
* fix(desktop): preserve interim assistant text wiped at message.complete

When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.

This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).

The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.

Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.

Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.

Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.

The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.

Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.

Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.

Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)

- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>

* fix: prefix-match interim streamed content to avoid benign duplicate bubbles

_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.

Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().

* test(desktop): add partial-stream-then-nudge dedup edge case

Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.

Acceptance protocol #2 — covers all three dedup edges:
  1. interim == final (existing)
  2. interim = strict prefix of final (existing)
  3. partial-stream-then-nudge (this commit)

---------

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
2026-07-20 11:42:29 -04:00
Teknium
58391436f7 fix: reconcile probe cache with stale-entry invalidation + stale test fixtures
- The #44861 stale-cache guard invalidated any cached value that differed
  from the static table, which would have discarded legitimate
  probe-derived windows larger than the table. Treat the table as a
  FLOOR: only drop under-reporting cache entries.
- Update probe test fixtures that predated the 4.6+ 1M table flip
  (opus-4-6 fallback expectations 200K -> 1M).
2026-07-20 05:49:44 -07:00
kubolko
6be4944bc0 fix(bedrock): probe real context window instead of stale static table
Bedrock models resolved their context window from a hardcoded table
(BEDROCK_CONTEXT_LENGTHS) keyed by longest-substring match. AWS ships
new model versions faster than the table tracks, so a new model like
claude-opus-4-8 (1M-token window) silently matched the older
"anthropic.claude-opus-4" entry and got capped at 200K — wasting 80%
of the available context.

Bedrock exposes the real window nowhere in metadata: get-foundation-model
omits it, Converse usage metrics omit it, CountTokens is unsupported on
several models. The only authoritative source is the ValidationException
raised when a prompt exceeds the window:

    "prompt is too long: 1300032 tokens > 1000000 maximum"

Length validation runs before inference, so an oversized request is
rejected immediately and cheaply (no tokens generated, no input
processed). This adds probe_bedrock_context_length(): pad a request just
past a tier, parse the reported maximum, return it. get_bedrock_context_length()
now probes first and falls back to the static table only when the probe
can't run (missing creds, network error, unparseable error). The static
table stays as a safety net.

get_model_context_length() caches the probe result per model+region, so
the network cost is paid once, not every turn. probe=False / empty region
disables probing for offline/display paths — backward compatible with the
single-arg callers.

Verified E2E against live Bedrock (eu-central-1): claude-opus-4-8 resolves
to 1000000. Unit tests cover error parsing, unparseable errors, missing
client, probe-beats-table, and table fallback.
2026-07-20 05:49:44 -07:00
Avi Fenesh
77ba81f75f fix(bedrock): add Fable + Claude 4.6/4.7/4.8 1M entries to context table, drop stale cached values
BEDROCK_CONTEXT_LENGTHS was missing entries for current 1M-context Claude
models, and the resolution path in get_model_context_length() short-circuits
to that table (step 1b) before DEFAULT_CONTEXT_LENGTHS is ever consulted, so
the catalog's correct values could never apply on Bedrock:

- claude-fable-5 (no entry at all) fell through to
  BEDROCK_DEFAULT_CONTEXT_LENGTH and reported 128K for a 1M model.
- opus-4-7 / opus-4-8 substring-matched the generic 'anthropic.claude-opus-4'
  key and reported 200K.
- opus-4-6 / sonnet-4-6 had explicit 200K entries predating their 1M windows.

The practical symptom: the agent compresses context prematurely (at ~128K or
~200K of a 1M window) on every Bedrock-hosted current Claude model.

Fixing the table alone is not enough for existing installs: a previously
persisted 128K/200K value in the context-length cache wins at step 1 and
masks the corrected table forever. Step 1 now reconciles Bedrock-context
cache hits against the static table (the table is authoritative for Bedrock
— there is no live probe to reconcile against), invalidating stale entries
so existing users converge to the right window without manual cache surgery.

Tests cover the new table entries (incl. inference-profile and versioned ID
forms), the 128K-default regression for Fable, the stale-cache invalidation
path, and that pre-4.6 models keep their 200K entries.
2026-07-20 05:38:55 -07:00
Drexuxux
487574c5b6 fix(pricing): strip apac./au. Bedrock region prefixes so cost isn't unknown
_normalize_bedrock_model_name stripped ("us.", "global.", "eu.", "ap.",
"jp.") before the pricing lookup, but AWS Bedrock's Asia-Pacific
cross-region inference profiles are prefixed "apac." (and Australia
"au."), not "ap.". A bare "ap." never matches an "apac.*" id
(str.startswith stops at the 'a' where "ap." expects '.'), so
"apac.anthropic.claude-*" and "au.anthropic.claude-*" fell through with
the prefix intact, missed the bare "anthropic.claude-*" pricing key, and
every Asia-Pacific / Australia Bedrock session priced as "unknown" — no
cost estimate or tracking for two whole geographies, while us./eu./global.
worked.

Add "apac." and "au." to the strip list (mirrors the same fix landing in
bedrock_adapter.is_anthropic_bedrock_model via #46297, which covers the
prompt-caching capability gate but not this duplicated cost-lookup copy).

Extends the existing cross-region pricing test to cover apac./au.; without
the fix it fails with scoped == None for "apac.".
2026-07-20 05:38:45 -07:00
Jake Tracey
4e4904c379 fix(bedrock): recognise au./apac. inference profiles to enable prompt caching
is_anthropic_bedrock_model() strips a regional prefix before checking for
"anthropic.claude" to route Claude through the AnthropicBedrock SDK path
(prompt caching, thinking budgets) instead of the Converse path. The prefix
list was missing "au." and "ap." does not match "apac.", so AU/APAC Claude
inference profiles silently lost prompt caching. Add "apac." and "au.".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 05:38:45 -07:00
polyhistor
a7e911150c fix(bedrock): address review — route list-string items through _safe_text, update stale placeholder assertions
Addresses hermes-sweeper review on PR #66167:

1. _convert_content_to_converse() still emitted {"text": part} directly
   for plain-string items inside a content list (as opposed to
   {"type": "text"} dicts), bypassing _safe_text() entirely. A
   whitespace-only string item (e.g. ["   "]) could still reach Bedrock
   as a blank block. Now routed through _safe_text().

2. tests/agent/test_bedrock_adapter.py::TestEmptyTextBlockFix asserted
   the pre-fix behavior (whitespace -> literal space " "), contradicting
   the new _safe_text()/_EMPTY_TEXT_PLACEHOLDER behavior added in
   4618095a. Updated assertions to expect the non-whitespace placeholder,
   plus added a regression test for the list-string-item case above.
2026-07-20 03:27:38 -07:00
polyhistor
9172048a2f fix(bedrock): use non-whitespace placeholder for empty text blocks
Bedrock Converse rejects text content blocks that are empty OR
whitespace-only (ValidationException: "text content blocks must
contain non-whitespace text"). The prior fix attempt substituted a
single space (" ") for missing content -- but a lone space IS
whitespace, so it was rejected by the exact same validation rule it
was meant to satisfy. This caused a deterministic, unrecoverable
retry-loop failure once any blank/whitespace assistant, tool, or user
turn entered history (most commonly via context-compaction rewriting
a turn to a blank string).

Adds _safe_text()/_EMPTY_TEXT_PLACEHOLDER ("(empty)") and applies it
everywhere a blank text block could reach the wire: user/assistant
content conversion, tool results, the assistant-empty-turn fallback,
and the first/last-message user-alternation padding. System-prompt
blocks are the one exception: blank parts are dropped entirely rather
than placeholder-filled, since a system prompt block should never
carry meaningless placeholder text.

Adds tests/agent/test_bedrock_empty_text_blocks.py (11 tests, was
already present uncommitted -- codifies the exact failing history
from issue #9486 and asserts no blank block ever reaches Bedrock).

Verified against the actual failed request dump from this session
(27-message payload) -- replaying it through the fixed converter now
produces zero blank/whitespace-only blocks.
2026-07-20 03:27:38 -07:00
iizotov
81d4b3654a fix(bedrock): map Claude opus/sonnet 4.6+ to 1M context window
The Bedrock static context table only had entries up to the 4-6
generation and capped all Claude models at 200K. Newer model IDs
(opus/sonnet 4-7, 4-8) silently inherited 200K via the generic
"anthropic.claude-opus-4" / "...-sonnet-4" substring fallback,
contradicting the native Anthropic table in model_metadata.py which
already maps these to 1M.

Map opus/sonnet 4-6/4-7/4-8 to 1_000_000 on Bedrock. Haiku 4.5,
sonnet 4.5, legacy Claude 4 and 3.x stay at 200K (no 1M window).

Add opus-4-8 coverage and haiku/sonnet-4-5 200K guards.
2026-07-20 03:27:28 -07:00
Patrick Muller
a8602e4afa test(bedrock): update context-length tests to match Anthropic docs
Update TestBedrockContextLength to assert the corrected values from
BEDROCK_CONTEXT_LENGTHS:

- test_claude_opus_4_6: 200_000 -> 1_000_000 (1M GA for Opus 4.6)
- test_claude_sonnet_versioned: 200_000 -> 1_000_000 (1M GA for Sonnet 4.6)
- test_inference_profile_resolves: 200_000 -> 1_000_000
  (us.anthropic.claude-sonnet-4-6 resolves to Sonnet 4.6's 1M value)

Also adds three new test cases that document Anthropic's published
context windows explicitly and guard against future regressions:

- test_claude_opus_4_7: asserts 1_000_000 and cites the models overview
- test_claude_sonnet_4_5_is_200k: asserts 200_000 and cites the
  April 30, 2026 release note retiring the 1M beta for Sonnet 4.5
- test_claude_haiku_4_5_is_200k: asserts 200_000 for Haiku 4.5

All 178 tests in test_bedrock_adapter.py pass after the change.
2026-07-20 03:27:28 -07:00
Teknium
3441b80f4f feat(pricing): add Bedrock rows for Opus 4.8/4.7, correct Opus 4.6 to $5/$25
Adds current-gen Claude Opus pricing rows on Bedrock keyed to Anthropic's
published list price, which commercial Bedrock on-demand mirrors. Also
corrects the existing Opus 4.6 row: it carried Claude-3-era Opus pricing
($15/$75); Opus 4.5+ list at $5/$25 with cache write 1.25x / read 0.1x.

The AWS Price List API had not published these SKUs machine-readably as
of 2026-07, so these are commercial-list snapshots pending an
authoritative machine source.

Reapplied from PR #62327 (commit authored under a placeholder identity,
so cherry-pick was not usable; sonnet-5 row from that PR already landed
via #67932).

Co-authored-by: pgregg88 <4943027+pgregg88@users.noreply.github.com>
2026-07-20 03:27:16 -07:00
Osraka
e101bbaebb fix(pricing): restrict Bedrock profile normalization 2026-07-20 03:27:16 -07:00
Osraka
54418a888e fix(pricing): resolve versioned Bedrock profile IDs 2026-07-20 03:27:16 -07:00
Jan-Stefan Janetzky
766c617e83 fix(compression): detect semantic no-op results 2026-07-20 02:25:57 -07:00
Jan-Stefan Janetzky
75af6dc57c fix(redaction): normalize URL credential key aliases 2026-07-20 02:25:57 -07:00