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.
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
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>
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).
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
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.
The salvaged retention check compared the built-in memory snapshot
before vs after the disk reload. That holds for a long-lived CLI agent,
but on fresh-agent surfaces (gateway per-turn agents, TUI) the cached
prompt is restored from the session DB and can predate mid-session
memory writes that the fresh MemoryStore already absorbed at init: the
snapshot is then identical on both sides of the reload while the prompt
itself is stale, so compression would retain (and re-persist via
update_system_prompt) a prompt missing the new memory for the life of
the session.
Replace the equality check with a containment check
(_cached_prompt_reflects_builtin_memory): retain the cached prompt only
when the freshly-reloaded rendered blocks appear verbatim inside it,
and rebuild when a leftover block header remains for a target whose
entries have since been emptied or disabled. Block headers are shared
via MEMORY_BLOCK_HEADERS in tools/memory_tool.py so the check stays in
lockstep with MemoryStore._render_block.
Adds regression guards for the gateway stale-restore path and the
emptied-memory leftover-block path; verified with a real-MemoryStore
E2E matrix (9 scenarios) against a temp HERMES_HOME.
Follow-up to the salvaged #66083/#42792 commits:
- alibaba (Qwen Cloud coding-intl) gets qwen3.7-plus too — same platform
allowlist as alibaba-coding-plan (issue #44662 comment by @coder-movers)
- qwen3-max substring context entry (262144) so the newly-listed
qwen3-max-2026-01-23 snapshot doesn't fall to the generic 131072 qwen
fallback
Add qwen3.7-plus to DEFAULT_CONTEXT_LENGTHS with 1M context window.
Without this entry, the model falls back to the generic 'qwen' entry
(128K), causing premature context compression at 50% (64K tokens)
instead of the correct 500K threshold.
Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
_find_tail_cut_by_tokens aligns cut_idx away from tool-call/result
boundaries (_align_boundary_backward), and both tail anchors re-align after
moving it. The final statement then raised the result to head_end + 1 so
compression always claims at least one message — without that floor the
caller's compress_start >= compress_end guard turns the pass into a no-op
that re-runs forever.
That raise discarded the alignment. When the floor landed inside a tool
group, the parent assistant(tool_calls) fell in the summarised region while
its tool results started the tail, and _sanitize_tool_pairs dropped those
orphans outright — so the tool output was neither summarised nor kept. It
vanished. That is exactly the silent loss _align_boundary_backward's own
docstring says the alignment exists to prevent.
Two back-to-back tool calls are enough to trigger it on default settings
(protect_first_n=3):
system, assistant(call_1), tool, tool, assistant(call_2), tool
aligned cut = 4 (keeps call_2's group together)
returned cut = 5 (floor overrode it)
summarised region = [assistant(call_2)]
tail = [tool(call_2)] -> orphan -> dropped
Sweeping every well-formed block layout up to length 6 (21840 transcripts),
5623 of them — 26% — split a call/result pair this way.
Re-align FORWARD after applying the floor. Forward, never backward: pulling
back would hand return the message the floor just claimed and reopen the
no-op loop. Sliding forward instead moves the cut past the end of the group,
so the whole call/result pair is summarised together and nothing is
orphaned. The same sweep reports 0 violations after the change, and the
progress guarantee is pinned by its own test.
Every API iteration computed `total_chars = sum(len(str(msg)) ...)`, which
str()-serializes the ENTIRE history — including base64 images and large tool
results — just to take its length, then called estimate_request_tokens_rough,
which walked the messages a SECOND time (it re-runs estimate_messages_tokens_rough
internally, already computed one line above).
Now derive both from one image-stripped message estimate:
approx_tokens = estimate_messages_tokens_rough(api_messages) # once
request_pressure_tokens = approx_tokens + tools_tokens # == old value
total_chars = approx_tokens * 4 # log/metric only
request_pressure_tokens is byte-identical to the old
estimate_request_tokens_rough(api_messages, tools=agent.tools or None) (no
system_prompt arg → messages + tools). total_chars only feeds a verbose log and
the pre-api-request hook's request_char_count, so a rough proxy is fine and it no
longer balloons on image turns. On the TTFT critical path for every call.
tests/agent/test_model_metadata.py + test_compressor_image_tokens.py green.
grok-4.5 is xAI's newest release (their versioning is non-monotonic:
4.5 > 4.20) and is the model xAI's own docs use for the server-side
x_search tool. Users who explicitly pinned x_search.model keep their
choice; everyone else picks up the new default via the config
deep-merge — no _config_version bump needed.
- tools/x_search_tool.py: DEFAULT_X_SEARCH_MODEL
- hermes_cli/config.py: DEFAULT_CONFIG x_search.model + comment
- agent/reasoning_timeouts.py: 300s stale-timeout floor entry for
grok-4.5 (grok-4.20-reasoning entry kept for pinned users)
- docs: x-search.md en + zh-Hans (config sample + troubleshooting)
- tests: default-model assertion + timeout-floor positive case
Kimi's Anthropic-compatible endpoints (api.moonshot.cn/anthropic,
api.kimi.com/coding) implement the adaptive thinking contract — they
accept thinking.type=adaptive + output_config.effort (all of low,
medium, high, xhigh, max verified live) and return thinking blocks, and
the replay-validation 400s that originally motivated dropping the
parameter (#13848) no longer occur.
_supports_adaptive_thinking() now returns True for Kimi-family models,
so they get thinking={type: adaptive, display: summarized} +
output_config.effort via ADAPTIVE_EFFORT_MAP instead of nothing, and
the blanket drop of the thinking parameter for Kimi-family endpoints is
removed. MiniMax and other non-adaptive third parties keep the manual
budget_tokens path; Claude behavior is unchanged.
The per-message ephemeral context prompt re-renders every turn, and
any byte change (Discord auto-thread rename, reset notes, voice
channel state) both breaks the provider prompt-cache prefix at the
head of every request and changes the gateway agent-cache signature,
forcing a full agent rebuild per message. Pin the rendered block per
session keyed by a hash of exactly the fields it renders, so only a
real input change (rename, topic edit, /sethome, redact_pii flip)
re-renders; deliver one-shot per-turn facts (auto-reset note,
first-contact intro, voice-channel changes) on the current user
message via the api_content sidecar instead of the system prompt; sort
get_connected_platforms for byte-stable ordering.
_manage_thinking_signatures treated every Kimi-family endpoint with the
#13848-era contract: strip signed Anthropic thinking blocks from replayed
history, assuming the upstream cannot validate Anthropic signatures.
Live probing shows that contract is outdated for the whole Kimi family:
- Kimi For Coding (api.kimi.com/coding) issues AND validates its own
thinking signatures (K3+): both verbatim and content-mutated signed
blocks replay with HTTP 200;
- Moonshot's Anthropic surface (api.moonshot.cn/anthropic) accepts signed
blocks the same way (200 on both verbatim and mutated);
- every other harness that replays signed blocks to KFC (Claude Code, pi,
Kilo Code) round-trips fine.
Stripping signed blocks there silently discarded the model's prior
chain-of-thought in multi-turn conversations — e.g. a two-turn recall
probe loses the reasoning between turns while the text answer survives
(agent.log: turn-2 input ≈ turn-1 input + a few dozen tokens instead of
+thinking). With this change, the same probe recalls the exact hidden
values from turn-1 thinking (+230 tokens on turn 2).
So: on _is_kimi_family_endpoint, keep signed and unsigned thinking blocks
unchanged on replay — one uniform rule for the whole Kimi family, no
/coding-vs-Moonshot split. DeepSeek keeps the #16748 contract (strip
signed, preserve unsigned). Third-party and direct-Anthropic behavior is
untouched.
Add tests/agent/test_anthropic_kimi_signed_thinking_replay.py pinning the
unified behavior (Kimi /coding + Moonshot keep signed and unsigned) and
the unchanged neighbors (DeepSeek strips, direct Anthropic keeps).
Background-review forks share the live parent's session_id for prompt-cache
warmth but are _persist_disabled — they can never write to the transcript.
Without this, every review fork on an active session would (a) trip a false
cross-agent overlap warning against the parent's real turn, sending the
#64934 route investigation the wrong way, and (b) pop the parent's in-flight
slot at its own persist, making a real overlap right after a review go
unreported. Both legs now skip persist-disabled agents symmetrically.
+2 tests.
note_turn_start kept its in-flight marker on the agent object, but the
gateway caches agents per routing key (_agent_cache) while transcripts
are owned by session_id — and switch_session (/resume from a second
surface, CLI-continuity rebinding, async-delegation pinning,
topic-binding tip-walks) maps multiple routing keys onto one session_id
without any cross-key check. Two keys mapped to one session run
concurrent turns on two different agent objects, so the per-agent
tripwire could never fire for exactly the dispatch route #64934 is
waiting to identify.
Add a module-level session_id-keyed in-flight registry alongside the
per-agent marker. Same philosophy as the original tripwire: log-only,
takes ownership on overlap, under-reports rather than double-reports
(a same-agent overlap warns once, not twice). The persist-time clear
pops the session id stamped at turn start, so a mid-turn compression
rotation of agent.session_id cannot strand the slot.
The ineffective-compression counter is not durable; when it is the sole
reason the gate is blocked there is nothing in the DB that could unblock
it, so re-reading the guard rows on every gate check for the rest of the
session is pure waste. Guard test pins the no-DB-touch behavior.
Follow-up on the salvaged #64511 commit:
- _automatic_compression_blocked() now refreshes durable guard state
(cooldown + fallback streak) when — and only when — the in-memory
snapshot says blocked, then re-evaluates. The should_compress()
pre-gates (preflight/turn paths) consult this before ever reaching
compress_context, and a stale fallback streak has no expiry timer, so
without a gate-level refresh a cleared durable row could never unblock
a prebound agent. The unblocked hot path pays no DB reads.
- A refresh that finds no durable cooldown row no longer clears a live
local cooldown whose DB persist FAILED (_cooldown_persist_failed):
an empty row is not evidence another agent cleared it, and honouring
it would reopen the #11529 thrash window. A successful durable
round-trip (record or read) makes the DB authoritative again.
- Guard tests for both directions (red on the pre-fix code), including
a hot-path test asserting the unblocked gate never touches the DB.
Follow-up hardening on top of the salvaged #66637 commits:
- _insert_real_user_anchor could place the restored human turn directly
next to user-role scaffolding (index-0 insert before a leading synthetic
user turn, or a scaffolding-only transcript), breaking the strict
alternation contract (#55677). Restoration now merges into trailing
scaffolding (anchor text leads, synthetic flags cleared) and appends
after a user-role compaction summary instead of inserting adjacent.
- _is_real_user_message now also rejects user-role compaction summaries
(the compressor pins the summary to role=user when the tail opens with
an assistant turn), so a summary can no longer satisfy the human-anchor
check and skip restoration.
- _latest_user_task_snapshot reuses the same real-user predicate, so the
deterministic task snapshot can no longer anchor on todo snapshots,
truncation notices, or background-process reports.
- The Historical Task Snapshot rewrite keeps the section terminated with
a blank line; the previous replacement consumed the boundary newlines,
gluing the next '## ' heading mid-line and deleting all later sections
on the next iterative compaction.
- Drop _length_continuation_synthetic (no producer anywhere).
- AUTHOR_MAP entry for enzo-adami.
Deduplicates the sidecar handling across 9 sites:
- substitute_api_content(): 2 API-bound pop+substitute sites
(chat_completion_helpers, transport — conversation_loop keeps its
inline pop because the current-turn compose fallback needs the value)
- drop_stale_api_content(): 4 rewrite-drop sites
(context_compressor x2, replay_cleanup, agent_runtime_helpers)
- extract_api_content_sidecar(): 3 gateway forwarding sites
(gateway/session, gateway/slash_commands, cli_commands_mixin)
Also: restore the eager _pending_cli_user_message clear on the early
row-creation try (was lost when the crash-persist moved after prefetch —
a crash in compression between the two tries would leave a stale staged
input), and fix a comment indentation nit in run_agent.py.
Co-authored-by: Soju06 <qlskssk@gmail.com>
The first LLM call of every gateway turn gets ~0% provider prompt-cache
hit rate (in-turn calls: 97-99%) because the bytes sent for a turn's
user message are not the bytes replayed next turn: memory-prefetch and
pre_llm_call context are injected into the API copy only, the #48677
persist override writes cleaned content to the DB row, and
get_messages_as_conversation sanitize/strips user and assistant content
on load. Any of these diverges the request prefix at that message and
re-prefills everything after it — measured 27.9s for the first call vs
2.4-5.8s cached at a median ~156k-token context.
Persist what you send: a nullable messages.api_content column stores the
exact content string sent to the API when it differs from the clean
stored content, and replay substitutes it verbatim (no sanitize, no
strip). The injection composition lives in one helper
(turn_context.compose_user_api_content); the turn prologue stamps its
output onto the live user message, the api_messages build sends the
stamped bytes, and every outgoing copy pops the field so it never
reaches a provider. The crash-resilience user-turn persist moves after
prefetch/pre_llm_call so the user row is written once with its final
sidecar; _ensure_db_session stays before preflight compression (session
rotation needs the parent row under PRAGMA foreign_keys=ON). The
current-turn index trackers are re-anchored after compaction rebuilds
the message list, in-place preflight compaction backfills the stamp onto
the already-inserted row, and gateway replay forwards the sidecar only
when the replay pipeline did not rewrite the content. Rewrite paths that
would leave stale bytes (historical image strip, merge-summary-into-
tail, consecutive-user repair merge, stale-confirmation redaction) drop
the sidecar; the chat-completions transport and the max-iterations
summary path strip it defensively. codex_app_server and MoA turns are
excluded from stamping because their wire bytes differ from the
composition. A missing or dropped sidecar degrades to today's behavior
(one cache-boundary miss), never to wrong content.
Extract the inline pure-tool-call tail check to a named helper using
flatten_message_text (canonical content extraction). Fix a SQLite
durability regression: the incremental tool-call persist
(conversation_loop.py:4990) stamps _DB_PERSISTED_MARKER on the assistant
row, so the next _persist_session flush skips it — the filled content
reaches the in-memory transcript but NOT the durable store, and /resume
reloads content="". Pop the marker so the next flush re-writes the row.
Tests pass, ruff clean.
`finalize_turn` guarantees the invariant "delivered final_response =>
assistant row in transcript" (#43849 / #44100) for recovery paths that
return a response without appending a closing assistant message. It
enforces it by checking only the tail's ROLE:
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
A tail that is a *pure tool-call turn* — `assistant(tool_calls=[...])`
with no text of its own — satisfies that check while carrying none of the
delivered answer. The append is skipped and the response is never
persisted, so the durable transcript ends at an assistant row the user
never saw as the reply. On the next turn the model replays the user
backlog and re-answers it: exactly the symptom the block was added to
prevent, just reached through a different tail shape.
Observed with the real finalizer: with messages ending
`user -> assistant(content="", tool_calls=[t1])` and
`final_response="Here is your answer."`, the persisted transcript keeps
`content=""` and the answer is absent.
Fill that row's empty content instead of appending. This keeps the
invariant without disturbing the tool-call structure and without creating
an assistant->assistant pair. A tail tool-call row that already carries
model text is left untouched, so no model output is ever overwritten.
Adds regression tests for both: the empty tool-call tail is filled, and a
tool-call tail with existing text is not clobbered.
Direct-Anthropic requests used a single shared _anthropic_client, and the
stale/interrupt watchdog closed + rebuilt it from the poll (stranger) thread
at four sites (non-streaming stale/interrupt, streaming stale/interrupt).
Closing a client whose TLS socket a worker thread was still reading released
the FD from a stranger thread; the kernel recycled it under a live SSL BIO,
which then wrote a 24-byte TLS record into an unrelated SQLite header
(cron/executions.db), bricking every cron on the profile. Same shape as the
OpenAI-only #29507 fix, but the Anthropic path never got the owner-thread
contract.
Extend the #29507 ownership contract to Anthropic: build a per-request client
(_create_request_anthropic_client), register it with the request-client
holder tagged by kind, and route _close_request_client_once by kind — a
stranger thread only shuts the request client's sockets down
(_abort_request_anthropic_client), while the owning worker performs the SDK
close (_close_request_anthropic_client). The shared _anthropic_client is now
never closed from inside a request (streaming or non-streaming), including the
worker retry-cleanup sites, since each attempt builds a fresh request client.
The #28161 no-hang guarantee is preserved: the poll-thread socket abort
unblocks the worker immediately.
Salvages the approach from #51688 (@raymondyan-zhijie), reimplemented onto
current main (non-streaming dispatch was refactored into
_dispatch_nonstreaming_api_request; streaming grew _cancel_current_stream_attempt
and worker retry-cleanup sites). Tests updated to the request-local mechanism
(incl. replacing a banned source-reading test with a behavior test) plus new
regression coverage proving the watchdog aborts the request client and never
touches the shared client.
Co-authored-by: raymondyan-zhijie <32435458+raymondyan-zhijie@users.noreply.github.com>
The system prompt building code hardcoded '~/.hermes' paths instead of
using get_hermes_home(). When HERMES_HOME is set to a custom location,
the prompt text still referenced ~/.hermes, confusing the AI about
where files actually live.
Changed:
- Import get_hermes_home from hermes_constants
- Default profile hint: ~/.hermes/profiles/<name>/ → <home>/profiles/<name>/
- Non-default profile hint: ~/.hermes/... → <home>/... for all paths
Closes#66450
_resolve_task_provider_model() read api_key from the auxiliary task
config but never consulted key_env (or api_key_env). When a user
configured an auxiliary task with key_env instead of a plaintext
api_key, the resolved API key was None, causing 401 on every call.
Add the same key_env → os.getenv() resolution pattern already used in
_fallback_entry_api_key() and named custom provider resolution.
Closes#66641
Hermes' computer_use wrapper dropped cua-driver's structured action verdicts,
exposed no delivery_mode, and injected background-only guidance — so the agent
reported unverified no-ops as success and concluded cua-driver 'cannot drive'
Electron/Chromium surfaces (observed live on tldraw offline). Fixes#67052.
Phase A — preserve the result contract:
- ActionResult carries verified/effect/escalation/path/degraded/code/delivery_mode
- CuaDriverBackend._action() reads structuredContent (was data-only); a helper
normalizes it, additive and None-safe on old drivers
- _text_response surfaces the fields additively (ok stays transport-only)
Phase B — bounded, model-reachable foreground:
- delivery_mode (background|foreground) + bring_to_front on the schema, dispatcher,
ABC, and all input methods
- foreground is capability-gated (input.delivery_mode); old drivers get a
structured foreground_unsupported refusal, never a silent background downgrade
- no automatic/hidden foreground retry — the model selects it from the signal
Phase C — guidance + isolation:
- system prompt (prompt_builder) and bundled skills/computer-use/SKILL.md go from
background-ONLY to background-FIRST, teaching the AX→PX→foreground ladder driven
by returned effect/escalation, not predicted from the app being Electron
- foreground approval scoped by (action, delivery_mode): a background approval
never silently authorizes foreground
- approval state keyed per session_id so concurrent gateway runs don't leak unlocks
Tests: tests/tools/test_computer_use_delivery_ladder.py (15) cover confirmed/
unverifiable/suspected_noop/degraded/old-driver verdicts, delivery_mode gating +
foreground_unsupported, and session-scoped foreground approval. Existing 265
computer_use tests still green.
Live E2E (real cua-driver 0.8.3 + tldraw offline on Linux/X11): a background click
returned effect='unverifiable'/path='ax' (no fabricated success), and a foreground
request returned code='foreground_unsupported' — correct on a driver that predates
the input.delivery_mode capability.
Provider empty-reply text mentions "very low max_tokens", which used to match
the bare overflow pattern and thrash compress until "Cannot compress further".