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.
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.
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.
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.
The previous commit in this branch bumped claude-sonnet-4-5 and
claude-haiku-4-5 to 1_000_000 on the assumption the context-1m-2025-08-07
beta enabled 1M on all Claude 4.x models. Verification against Anthropic's
own documentation shows that is incorrect:
- Claude Haiku 4.5 is a standard 200K model per
https://platform.claude.com/docs/en/about-claude/models/overview
(the 'Latest models comparison' table shows '200k tokens' for Haiku 4.5).
- Claude Sonnet 4.5 had its 1M beta retired on April 30, 2026 per
https://platform.claude.com/docs/en/release-notes/overview:
'We've retired the 1M token context window beta (context-1m-2025-08-07)
for Claude Sonnet 4.5 and Claude Sonnet 4. The beta header now has no
effect on these models, and requests exceeding the standard 200k-token
context window return an error.'
Revert both entries to 200_000. Opus 4.7, Opus 4.6, and Sonnet 4.6
remain at 1_000_000 — those three have 1M generally available with no
beta header required per the same source.
Also updates the header comment to cite the Anthropic models overview
and the April 30 2026 release note so future readers have an upstream
source of truth.
Claude 4.x models on Bedrock support a 1M-token context window via the
context-1m-2025-08-07 beta header, which Hermes already injects
automatically in build_anthropic_bedrock_client
(agent/anthropic_adapter.py). However, BEDROCK_CONTEXT_LENGTHS in
agent/bedrock_adapter.py still reported 200K for opus-4-6, sonnet-4-6,
sonnet-4-5, and haiku-4-5, and had no entry at all for opus-4-7 (which
falls back via substring match to the 200K opus-4 entry).
This caused Hermes to display a 200K window, compress conversations
earlier than necessary (compression.threshold * 200K instead of * 1M),
and generally under-utilize the full 1M context users are paying for.
The fix is metadata-only — the Bedrock API and beta header already
support 1M end-to-end. agent/model_metadata.py's DEFAULT_CONTEXT_LENGTHS
table already lists claude-opus-4-7 / -4-6 / sonnet-4-6 at 1M for the
non-Bedrock paths, so this change brings the Bedrock table into
alignment.
Changes:
- Add anthropic.claude-opus-4-7 at 1_000_000
- Bump anthropic.claude-opus-4-6 from 200_000 to 1_000_000
- Bump anthropic.claude-sonnet-4-6 from 200_000 to 1_000_000
- Bump anthropic.claude-sonnet-4-5 from 200_000 to 1_000_000
- Bump anthropic.claude-haiku-4-5 from 200_000 to 1_000_000
- Add explanatory comment pointing readers at the beta-header injection
site in agent/anthropic_adapter.py
* fix(fast): default /fast to session scope on CLI and gateway
Completes the session-first policy from #67946 for the /fast toggle
(the remaining half of #54084). A bare /fast fast|normal now applies to
the current session only; --global persists agent.service_tier to
config.yaml.
Gateway: new _session_service_tier_overrides dict (registered in
_CONVERSATION_SCOPED_STATE so /new clears it) resolved at both agent
turn sites via _resolve_session_service_tier(); the /fast handler and
its choice picker apply session overrides and evict the cached agent.
The TUI config.set fast path was already session-scoped.
CLI: /fast parses --global (parity with /reasoning); bare toggles
mutate self.service_tier only.
* fix(sessions): /new resets model, reasoning, and fast to config defaults
/new and /reset are full conversation boundaries: session-scoped
runtime overrides do not carry into the next session (#48055, #23131).
CLI new_session(): clears the one-turn model restore, re-derives
service_tier from config, and — when the session's model differs from
the config default — switches back via the shared switch_model()
pipeline (live agent swap included; best-effort so an unreachable
default never blocks /new).
TUI _reset_session_agent(): stops forwarding model_override /
create_reasoning_override / create_service_tier_override into the
rebuilt agent and pops the pins so later rebuilds can't resurrect
them. The gateway already cleared its per-session overrides via
_clear_conversation_scope on /new.
Cross-session contamination stays impossible: nothing here touches
process-global env or other sessions' pins.
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>
judge_goal() returns (verdict, reason, parse_failed, wait_directive) since
the goals.py wait-directive change, but the kanban goal-mode completion gate
at tools/kanban_tools.py still unpacked 3 values. Every judge call raised
ValueError, the defensive except swallowed it, and the pre-initialized
verdict='done' let every completion through — the acceptance gate was
silently disabled.
Now unpacks all 4 values; the test mock is updated to match the real
contract. The other two judge_goal consumers (hermes_cli/goals.py) already
use 4-value unpacks.
Reported and diagnosed by @bill3wits in PR #57276; reimplemented under
project authorship because the original commit was authored under a
non-existent local identity (bash@hermes.local) that cannot be carried
into history. Also fixes#58066 (duplicate report by @Gibcity).
Sections 1-2 of ``list_authenticated_providers`` emit rows directly
from ``PROVIDER_REGISTRY`` (auth-driven built-ins) before reaching the
per-section gate I added for section 3 (user-config providers). That
means flipping ``providers.openrouter.enabled: false`` hid OpenRouter
from a user-config block but the built-in OpenRouter row still showed
because its row came from section 1's auth-status path.
Add a single post-filter at the end of ``list_authenticated_providers``
that drops every row whose ``provider_id`` or ``slug`` matches a
disabled name in ``providers``. Same source of truth, applied once at
the end, covers all four sections in one pass.
Wrapped in ``try/except`` so a degraded config can't break the picker —
if anything fails reading the config, the filter no-ops and the picker
shows the un-filtered list (same as before this PR).
The first commit's gate sat inside ``_get_named_custom_provider`` —
which only handles user-defined custom blocks. Built-in provider names
(``openai`` / ``anthropic`` / ``openrouter`` / ``gemini`` / ...) have
their own resolution paths in ``resolve_runtime_provider`` (pool /
explicit / generic / ``resolve_provider``) and bypass that gate.
So a user who flipped ``providers.openrouter.enabled: false`` would
still see OpenRouter resolved when something explicitly requested it
(e.g. a fallback chain entry). That defeats the point of the flag.
This commit moves the gate one level up: right after
``requested_provider`` is computed, before any custom / built-in /
Azure short-circuit. It now raises a typed ``ValueError`` referencing
the YAML path, so callers can recognise it and advance to the next
fallback instead of silently using a disabled provider.
3 new tests cover:
* disabled custom provider raises
* disabled built-in provider raises
* enabled provider doesn't hit the gate
All 20 tests in the providers suite pass.
A ``providers.<name>`` block in ``config.yaml`` can now opt out of being
listed anywhere by setting ``enabled: false`` — without removing the
block, so re-enabling it stays a one-line edit. Missing or ``true`` keeps
the previous behaviour (enabled), so this is fully backwards-compatible.
The flag is honoured in four places:
* ``hermes_cli/model_switch.py`` — model-override validation (the
allow-list that the picker consults to accept a non-public model id)
and the picker's own endpoint iteration. A disabled provider no longer
appears as a row and its models can't be silently accepted via
override.
* ``hermes_cli/runtime_provider.py`` — the runtime resolver skips
disabled blocks, so an explicit ``--provider X`` against a disabled
entry fails fast instead of using stale base_url / api_key from the
ignored block.
* ``hermes_cli/doctor.py`` — the doctor's "configured providers" set
excludes disabled entries, so health checks don't flag missing API
keys for providers the user has turned off.
Motivation: when a user has 20+ providers wired up in ``config.yaml``
(many of them only used occasionally) the picker becomes noisy and the
runtime resolver may pick a suboptimal one on ambiguous --provider names.
There's currently no way to hide a provider short of deleting its block
— which loses the api_key + base_url + custom routing config the user
spent time wiring. ``enabled: false`` lets them keep the config but get
it out of the way.
The helper ``is_provider_enabled()`` in ``hermes_cli/config.py``
centralises the gate (and accepts YAML-stringified booleans like
``"false"`` for hand-edited configs). 17 unit tests cover the defaults
and edge cases.
A follow-up PR can wire ``hermes provider enable/disable <name>`` and a
dashboard toggle on top of this primitive — they reduce to mutating the
flag.
The MemoryProvider.on_pre_compress() hook returns text that providers
want preserved in the compression summary, but run_agent.py discarded
the return value. Additionally, compress() and _generate_summary() had
no mechanism to accept this context.
- Capture on_pre_compress() return value in run_agent.py
- Add memory_context parameter to compress() and _generate_summary()
- Inject memory provider insights into summarization prompts
Fixes all MemoryProvider plugins that return context from
on_pre_compress() (currently silently broken for every plugin).
Follow-up for salvaged PR #39946: file_sync.py already aliases time.sleep
as _sleep specifically to avoid tests mutating the shared stdlib module
object. Apply the same convention to the rate-limit clock (_monotonic)
and point the new regression test at it.
FileSyncManager.sync() is rate-limited to once per _sync_interval via
_last_sync_time, and its docstring promises that on failure "state rolls
back so the next cycle retries everything". But the except handler also
set _last_sync_time = time.monotonic() on failure, so the next non-forced
sync() within the interval hit the rate-limit guard and returned early —
suppressing the retry the rollback had just prepared.
Because the non-forced sync() runs before every command on the SSH, Modal
and Daytona backends, a single transient upload failure (network blip,
dropped channel) left the remote with stale files for the next command
(up to _sync_interval, default 5s). Forced syncs bypass the guard, which
is why it was intermittent.
Remove the failure-path timestamp bump so the clock only advances on a
successful or no-op cycle, matching the documented contract. Add a
regression test that fails before this change and passes after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The salvaged #55853 commit added anthropic/claude-sonnet-5 blocks to the
manifest, but main already carried them from #56617 — leaving duplicate
entries the manifest-sync test rejects. Rebuilt via
scripts/build_model_catalog.py.
test_gmi_provider asserts fallback_models == _PROVIDER_MODELS["gmi"];
the salvaged plugin commit placed sonnet-5 after sonnet-4.6 while the
curated list has it before. Match the curated ordering.
PR #55848 and #60410 both added an (anthropic, claude-sonnet-5) pricing
key; the later duplicate (/$15 standard rate) would silently win in
the dict literal. Keep the intro pricing entry ($2/$10 through
2026-08-31 per Anthropic docs) which carries the reversion note.
Sonnet 5 launched 2026-06-30 with introductory pricing ($2/$10 per
MTok input/output) through 2026-08-31, after which it reverts to
$3/$15. The model had no entry in the official-docs pricing snapshot,
so any session on claude-sonnet-5 was tracked as cost_status=unknown
with $0 estimated cost -- silently hiding real spend from
hermes insights and any downstream cost sync.
Source: https://platform.claude.com/docs/en/about-claude/pricing
The test test_provider_model_ids_falls_back_to_static_models asserts
provider_model_ids('gmi') == list(_PROVIDER_MODELS['gmi']), but when
live API is unavailable the function returns fallback_models from the
provider profile instead of _PROVIDER_MODELS. Add claude-sonnet-5 to
the GMI plugin's fallback_models to match the curated list update.
PRs #55848 and #55853 both added claude-sonnet-5 to the anthropic and
gmi curated lists; keep one entry each (newest-sonnet-first ordering
under claude-fable-5, matching the existing list convention).
Add claude-sonnet-5 to the static curated lists for Anthropic, OpenRouter,
Nous Portal, Copilot, GMI, OpenCode Zen, and AWS Bedrock so the model
appears in hermes model / /model picker discovery.
Fixes#55846
The 'Unknown top-level config key' warning (f5bacee27) assumed a
closed-world allowlist of valid roots, but top-level scalars in
config.yaml are deliberately bridged into os.environ (gateway/run.py,
hermes send) so skills and external apps Hermes drives can read
arbitrary env-style keys (DISCORD_HOME_CHANNEL, MY_APP_TOKEN, ...).
An allowlist can never enumerate those — two widening follow-ups
(7c2ece53c, 3c7217706) already proved the whack-a-mole. Drop the
generic warning entirely; keep the targeted provider-like-field
misplacement hint (base_url/api_key at root).
Parity with the gateway /reasoning handler and the new /model default:
a bare /reasoning <level> now applies to the current session only;
--global persists agent.reasoning_effort to config.yaml. --session is
still accepted as an explicit alias for the default. Display toggles
(show/hide/full/clamp) remain persistent as before — they are user
preferences, not conversation state.
Builds on YAMAGUCHI Seiji's #51158 (session-scope plumbing + /new reset,
cherry-picked as the previous commit) with the default flipped to match
the session-first policy. Fixes the CLI half of #54084.
Flip the resolve_persist_behavior() fallback from persist-to-config to
session-only. A plain /model <name> (typed or via any picker — CLI,
TUI/Desktop, gateway) now affects only the current session; --global
persists explicitly, and model.persist_switch_by_default: true restores
the old opt-out behavior for users who want switches to stick.
This is the root cause behind the recurring 'session switch applied
globally' bug class (#61458, #63083, #58290, #61190): every surface
funnels its no-flag default through this one function, so per-surface
patches kept missing paths. Fixing the default fixes all surfaces at
once: CLI typed + picker, TUI/Desktop config.set + slash, gateway typed
+ inline picker.
Builds on liuhao1024's #58371 (--provider session scoping, cherry-picked
as the previous commit) and supersedes the per-surface #61488.
When /model is called with --provider but without --global or --session,
the switch now defaults to session-only instead of persisting to
config.yaml. Provider switches are typically exploratory — the user is
trying a different backend for this conversation, not reconfiguring the
default. --global can still force persist when desired.
This addresses a regression from fad4b40d9 where /model switched to
persist-by-default, causing /model xxx --provider xxx to overwrite the
global config when the user only intended a temporary switch.
Fixes#58290
Deduplicates the SimpleNamespace stub-response construction that was
copy-pasted between the tool-call-drop guard and the text-only-drop
guard (both in interruptible_streaming_api_call). The third site
(error-handler at ~L3775) has a structurally different shape (different
role/reasoning/model/usage sources + _content_filter_terminated tag)
and is left inline with an existing comment.
When a streaming response ends cleanly (HTTP 200) with no finish_reason
after delivering text but no tool calls, the chunk collector silently
stamps finish_reason='stop' and the conversation loop presents truncated
text as a complete response.
Three stream-drop paths now exist after chunk collection:
1. Zero-chunk → EmptyStreamError, retried (existing)
2. Tool-call in progress, no finish_reason → partial-stream-stub (existing)
3. Text-only, no finish_reason → partial-stream-stub (NEW — this fix)
Path 3 routes through the same PARTIAL_STREAM_STUB_ID + FINISH_REASON_LENGTH
machinery as path 2. The conversation loop shows 'Stream interrupted —
requesting continuation' and injects a continue prompt, giving the model
a chance to resume where the stream dropped.
Observed with DeepSeek provider where CloudFront drops SSE streams
mid-response after delivering partial text.
developers.cloudflare.com/cloudflare-one/connections/connect-networks/
returns 301 Moved Permanently to
/cloudflare-one/networks/connectors/cloudflare-tunnel/ after Cloudflare
reorganized the Cloudflare One docs. The new page is the named-tunnel
workflow this paragraph points readers at.