- Relax timing bound from 1.0s to 2.0s (CI machine robustness).
- Add test_long_dotted_run_with_keyword_completes_fast: includes a secret
keyword so the pre-gate does NOT skip _CFG_DOTTED_RE — directly exercises
the possessive-quantifier regex, not just the pre-gate.
- Add test_yaml_assign_redos_resistance: _YAML_ASSIGN_RE was modified but
had no ReDoS test — add 100-line stress input.
- Add test_yaml_assign_secret_still_redacted: verify YAML matching behavior
preserved with possessive quantifiers.
_CFG_DOTTED_RE's nested quantifier (?:[A-Za-z0-9_\-]+\.)+ backtracks
exponentially on long non-matching dotted runs (doubles every ~4
segments). Flatten it and use possessive quantifiers (py3.11+) in
_CFG_DOTTED_RE and _YAML_ASSIGN_RE wherever the successor is disjoint.
Zero behavior change: equivalence fuzz-verified over 120k structured
and random inputs comparing full sub() output including groups. Adds a
ReDoS regression test.
_call_fallback_candidate_sync replans messages/tools for each resolved
destination, but its async mirror still shipped the caller's decorated
sections verbatim — the primary destination's markers (including a
direct-native tool marker) leaked to fallback candidates with different
cache contracts, and the relay saw the display label instead of the
resolved provider/api_mode. Mirror the sync path: resolve the
destination, replan both sections, thread provider/api_mode through
_relay_async_completion, and replan again for the auth-refresh retry
client. Mutation-checked: the new parity test fails on the verbatim
pass-through shape.
Follow-up to #76032 (#20880).
The count walked every message part and tool schema on every request but
is consumed only by tests. Compute it on demand via a property instead.
Follow-up to #76032 (#20880).
Three copies of the same logic landed with #76032:
- MoA's _call_prepared_aggregator and auxiliary_client's
_replan_synchronous_cache_sections both implemented stub → policy →
strip → plan for a resolved destination. Extract
plan_cache_sections_for_destination() into agent_runtime_helpers (which
already owns the policy functions) and route both through it. Also
removes a redundant full-transcript deepcopy+strip per request (the
caller pre-stripped what build_prompt_cache_plan strips again).
- The fallback_chain[N] label regex + chain-entry lookup lived in
_fallback_entry_timeout AND _fallback_destination. Extract
_fallback_chain_entry() and reuse.
MoA's cache-plan failure log is promoted debug → warning: the call-block
site skips MoA, so this block is the aggregator's only decoration path —
a silent failure ships an undecorated request (the 0%-cache MoA bug class).
Behavior-preserving; 195 targeted tests green.
Follow-up to #76032 (#20880).
_apply_static_prefix_marker duplicated _apply_system_cache_markers' split
logic minus its empty-suffix guard: when the stored system prompt equals
the static prefix exactly, the tool-cache plan emitted a two-part split
with a trailing empty text block — HTTP 400 on native Anthropic. Fold the
tool-cache layout into the existing helper via mark_suffix /
fallback_to_whole flags; the empty-suffix case now marks the prompt as
one whole block. Behavior-parity verified against the merged planner for
every non-empty-suffix shape.
Follow-up to #76032 (#20880).
Review pass 2 (reuse reviewer HIGH): the step-3b probe-down fallback for
custom/local endpoints returns the same silent 256K default but only
logged at INFO - invisible by default, and it is the MORE common path
for small local models (the exact users the warning exists for).
Extract _warn_context_length_fallback() (deduped per model+base_url)
and call it from both fallback sites, per the fix-the-whole-bug-class
rule. Regression test drives the custom-endpoint path and fails without
the widening (mutation-checked).
Review follow-up:
- Warn once per (model, base_url) at the step-9 fallback via a module-level
dedup set (established _WARNED_* idiom). The fallback result is
deliberately never cached, so the un-deduped warning fired on every
resolution - e.g. once per gateway message via the session-hygiene path.
- Replace the three inline-mock pool-cleanup tests (which reproduced the
try/except block against a MagicMock and passed even with the production
code reverted) with a parametrized test that drives the real
BatchRunner.run() with a patched Pool; drop the CPython stdlib
signature change-detector test.
- Add a once-per-model warning regression test; clean up dead imports.
All tests verified to fail against pre-PR batch_runner.py/model_metadata.py
and pass with the fix (mutation check).
Salvage of #6629 by aaronlab (kshitijk4poor reworked against current main).
Three concerns from the original PR, reworked to address review feedback:
1. Context-length fallback diagnostic (agent/model_metadata.py):
get_model_context_length() silently returned 256K when all 9 detection
methods failed. Users with small-context models (8K, 32K) would get 256K
silently, causing hard-to-debug API context-length errors. Added a
warning log at the step 9 fallback with model name, base_url, and the
correct config override hint (model.context_length, not context_length).
The token-estimation ceiling-division fix from the original PR already
landed on main (5c2ecdec) with CJK handling — not duplicated here.
2. Fsync for batch trajectory writes (batch_runner.py):
Trajectory entries were written without flush/fsync, but the checkpoint
immediately marked them as completed. A crash between write and disk
sync would leave the checkpoint claiming completion with no trajectory
data on disk. Added flush() + os.fsync() before checkpoint update.
3. Pool cleanup on interruption (batch_runner.py):
Ctrl+C during pool.imap_unordered() relied on context manager cleanup
which can hang. Added explicit pool.terminate() + pool.join() for both
KeyboardInterrupt and Exception paths. The original PR used
pool.join(timeout=10) which is invalid — CPython's Pool.join() takes
no timeout parameter. Fixed to use pool.join() without arguments.
Tests:
- test_warning_emitted_on_fallback: verifies warning fires at step 9
- test_no_warning_when_cached: verifies no false warning when cache hits
- test_trajectory_entry_is_synced_to_disk: verifies os.fsync is called
- test_pool_terminate_called_on_exception: verifies cleanup on RuntimeError
- test_pool_terminate_called_on_keyboard_interrupt: verifies cleanup on Ctrl+C
- test_pool_join_called_without_timeout: verifies no timeout arg to join()
- test_real_pool_join_accepts_no_timeout: integration check on CPython API
Co-authored-by: Aaron Lab <aaronlab@users.noreply.github.com>
Let terminal keys explicitly present in config.yaml override matching stale TERMINAL_* values while preserving environment values for omitted keys. Merged defaults remain backfill-only.
Exercise the real config.yaml to _get_env_config path for backend selection, partial terminal sections, matching-key overrides, environment fallback, one-shot bridging, and config read failures.
Closes#71137
Each mid-session `/model` switch appended a `[System: The active model for
this chat has changed to …]` user-role marker to the live conversation history
and never removed the prior one. N switches left N stale markers, all re-sent
to the provider on every subsequent turn — the issue measured ~80-120 tokens
each (Chinese+English), so 5 MoA-preset switches burned ~400-600 context
tokens per API call, permanently. Only the newest marker is meaningful (it
names the currently-active model); the rest are pure waste. Fixes#65891.
`_append_model_switch_marker` now strips any earlier markers from
`session["history"]` (in place, under the existing history_lock) before
appending the new one, so the live payload carries exactly one marker. This is
the payload re-sent each turn, and it's self-healing across resumes: whatever
markers a history reload brings back, the next switch collapses to one.
A stable `_MODEL_SWITCH_MARKER_PREFIX` constant is shared by the builder and
the `_is_model_switch_marker` predicate so the two can't drift; the marker
string itself is byte-for-byte unchanged. Existing behavior (role='user' per
the single db.append_message persistence) is preserved.
Scope note: this dedups the in-memory history (the per-turn cost the issue
quantifies). The DB still persists one row per switch for audit/resume; I did
NOT soft-archive prior marker rows because `active=0, compacted=0` is the
repo's rewind/undo sentinel (hermes_state.py get_messages docs), so
deactivating a marker naively would make it look rewound — a follow-up that
needs its own archive semantics.
Tests (tests/tui_gateway/test_model_switch_marker_role.py, +5): second switch
replaces (not stacks) the marker; the issue's exact 5-MoA-switch sequence
leaves one marker naming the last model; real conversation turns are preserved
in order; a stale marker sitting between turns is stripped; history_version
increments once per switch. `pytest test_model_switch_marker_role.py` → 13
passed (8 existing unchanged). Authored on Windows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX
Setting prompt_caching.cache_ttl to a falsy value (false, null, off,
disabled, no, none) now fully disables prompt caching instead of
being silently ignored.
The disable propagates through anthropic_prompt_cache_policy() (early
return when _cache_disabled flag is set) and restore_primary_runtime()
(override after snapshot restore), so it survives /model switches and
fallback re-derivation — the gap that caused #56105 to be reverted in
#56126.
Salvage of #33555 by @BB-light, with model-switch/fallback survival
gap fixed on top.
Co-authored-by: BB-light <BB-light@users.noreply.github.com>
The claim 'late tools then require an explicit /reload-mcp' was false on
current main: the between-turns prologue refresh (agent/turn_context.py)
picks up late-connecting servers cache-safely at every turn boundary,
and ACP has no /reload-mcp. The daemon's real marginal value is tool-list
freshness in the [session created -> first message] window.
Review follow-ups on the #32811 salvage:
- Hold state.runtime_lock and bail on is_running so the pre-first-turn
guard can't race the first prompt dispatch (a refresh publishing
mid-turn would swap tools= and break the just-created cache prefix).
Regression test mutation-checked (guard removed -> test fails).
- In-memory-only session lookup in the daemon: get_session() falls
through to a DB restore that builds a whole new AIAgent just to
decide no-op (TUI equivalent also checks its in-memory dict only).
- Pass quiet_mode=True explicitly, matching the TUI/gateway callers.
- Use ensure_mcp_discovery_before_agent_build() (landed on main after
the PR) instead of bare wait_for_mcp_discovery() so the ACP agent
build is self-sufficient and gets the retry-after-zero-connected
allowance, matching CLI/one-shot construction sites.
ACP entry.py fires MCP discovery in a background daemon thread, but
_make_agent snapshots tools once at build and never re-reads the registry.
Unlike CLI/TUI, ACP had no bounded wait before the snapshot and no
late-refresh for configured (config.yaml) MCP servers — a reachable-but-
slow server that finished after agent build was invisible for the whole
session.
Changes:
- acp_adapter/session.py (_make_agent): call wait_for_mcp_discovery()
before AIAgent construction, bounded by mcp_discovery_timeout (default
~1.5s). A dead server can't block; servers that miss the bound are
picked up by the late-refresh below.
- acp_adapter/server.py (_schedule_mcp_late_refresh): new method on
HermesACPAgent — if discovery is still in flight after session creation,
spawns an off-critical-path daemon that joins it (bounded 30s), then
rebuilds the tool snapshot via the shared refresh_agent_mcp_tools helper.
Cache-safe: only runs pre-first-turn (_user_turn_count/_api_call_count
both 0); once the user has sent a message the snapshot is frozen, exactly
as TUI PR #48403 does.
- Called from new_session, load_session, resume_session.
- Mirrors the TUI pattern (tui_gateway _schedule_mcp_late_refresh, PR
#48403) and the CLI pattern (get_tool_definitions → wait_for_mcp_discovery).
Tests:
- Replace the AST-based test (source-text inspection) with three
behavioral regression tests in tests/acp_adapter/test_acp_mcp_discovery.py:
1. Blocked discovery does not block startup (non-blocking contract)
2. Delayed discovery lands tools via late-refresh (pre-first-turn)
3. Late-refresh is cache-safe: skips rebuild after first turn
Addresses teknium1 review on PR #32811.
Fire-and-forget MCP server connections on a daemon thread so the
gateway / CLI / ACP process becomes interactive immediately instead
of blocking on slow remote MCP servers (HTTP timeouts, sluggish
stdio boot). Previously `hermes --tui` waited 2-5 s after the splash
screen before rendering the UI while `discover_mcp_tools()` ran
synchronously on the critical path.
Changes:
- tools/mcp_tool.py: add `discover_mcp_tools_background()` — thin
wrapper that spawns `discover_mcp_tools()` on a named daemon thread
- tui_gateway/entry.py: call `discover_mcp_tools_background()` before
sending gateway.ready (replaces inline call that blocked the JSON-RPC
pipe for the TUI Ink app)
- hermes_cli/main.py:
- skip `\_prepare_agent_startup()` for TUI path — plugins, MCP, and
shell hooks are only needed by the CLI agent loop; the TUI's
gateway subprocess discovers them independently (~370 ms saved)
- fast-path in `\_make_tui_argv()`: when `dist/entry.js` exists and
is fresh, skip npm install / rebuild checks entirely (~350 ms saved)
- cli.py (`\_prepare_deferred_agent_startup`): same background pattern
for deferred startup (Termux interactive CLI)
- acp_adapter/entry.py: same pattern so ACP server launches asyncio
immediately while MCP connects in parallel
Result:
- TUI Python wrapper: ~730 ms → ~80 ms (9× faster)
- gateway.ready: ~2700 ms → ~400 ms (7× faster)
- Total TUI cold start: ~3400 ms → ~480 ms
Related: #29726, #29184, #19326 (closed stale)
Closes#29726
All-fixed splits used to promote the last track to flex-grow and drop its
max clamp. Review and files both declare maxWidth, so ⌘G/⌘J ballooned them
and sash overrides only set a basis that grow still expanded past.
EventBridge initialized each session's last_seen timestamp to 0.0, so
the first poll after 'hermes mcp serve' starts treated every saved
user/assistant message in state.db as a fresh events_poll event.
The fix establishes a per-session timestamp baseline on startup via
_establish_baseline(), recording the latest existing message timestamp
without emitting events. Only messages written after the baseline are
delivered on subsequent polls.
Also hoists _ts_float to module-level (needed by _establish_baseline)
and adds ImportError fallbacks for hermes_constants imports so the
bridge works in environments where the module isn't on the path.
Salvage of #13414 by @afurm, re-applied by @HeLLGURD in #41239.
Co-authored-by: afurm <afurm@users.noreply.github.com>
resolveNewSessionCwd now inherits the focused chat's workspace when you
aren't drilled into a sidebar project, so a new tab or draft stays in
the same repo as the chat you were looking at.
Workspace indicator was stuck on the primary $currentCwd while timers
and context already tracked focus. Resolve from the focused runtime
slice, then the stored session row, with a mid-switch ownership gate.
Path tips across chrome use displayPath (~/).
A $currentCwd listener in the terminal store picks the user tab whose
live shell cwd (restoreCwd, falling back to launch dir) matches the
session's workspace. Selection only: no tab is created, closed, or
revealed; detached sessions and unmatched cwds leave the rail alone,
and an already-matching active tab keeps focus.
Same gesture the pane tabs already carry — isMetaClose hoists from
pane-tab.tsx into lib/middle-click.ts beside its sibling middle-click
gesture, so the two surfaces share one predicate instead of drifting.
A turn that ended without its message.complete (turn crash, reconnect gap,
steer race) left its streaming bubble pending:true forever. The next user
message then landed after it, stranding a live dither indicator
mid-transcript.
Three layers:
- session.info running=false (the agent loop's finally-block signal, the
only settle edge those paths still emit) now finalizes the streaming
bubble via the same math as Stop.
- A fresh submit settles any leftover pending bubble before appending the
new user message, and drops a stale streamId so the new turn seeds fresh.
- AssistantMessage renders the loading/stall indicator only on the thread's
last message, so no upstream state bug can ever paint one mid-transcript.
Guard the early-auth pairing gap: unknown DMs must reach the gateway when
pairing is the effective unauthorized-DM behavior, while ignore mode and
unauthorized group senders stay rejected.
The #40863 intake prefilter rejected unauthorized DMs whenever an allowlist
existed, so gateway pairing never ran even when the operator set
telegram.unauthorized_dm_behavior: pair (which must win over the #9337
allowlist silence default). Pass those DMs through; groups stay blocked.
Non-interactive sessions (hermes chat -q, hermes -z) snapshot the tool
registry at AIAgent construction time. If background MCP discovery hasn't
finished, MCP tools are invisible for the entire session — and unlike
interactive mode, there is no between-turns late-binding refresh to recover.
Root cause: wait_for_mcp_discovery() only joins an already-created discovery
thread, so it no-ops if a direct/single-query path reaches agent construction
before MCP startup created that thread. Oneshot._run_agent() didn't call it
at all.
Fix:
- Add ensure_mcp_discovery_before_agent_build() helper to mcp_startup.py:
idempotently starts discovery if needed + bounded wait. Fail-open on errors.
- Add single_query parameter to _resolve_discovery_timeout/wait_for_mcp_discovery:
uses mcp_single_query_discovery_timeout (default 15s) instead of the
interactive mcp_discovery_timeout (1.5s) because one-shot sessions have no
second turn to recover.
- Wire into CLI _init_agent (single_query from _single_query_mode flag set
in cli.py's single-query path) and oneshot._run_agent (single_query=True).
- Interactive sessions unchanged: keep 1.5s bound (between-turns refresh covers).
Closes#38448, #51316, #37013, #68137
Composite salvage of #60017 (chrishart0), #51322 (Bartok9), #38620 (buptwz),
#43544 (halonke), #36882 (vanhoof).
mirrorSelection called textarea.select() whenever xterm had a scrap,
which replaced any chat highlight so ⌘C copied the wrong thing. Only
claim the document selection while the terminal is focused and nothing
outside it is highlighted.
User bubbles are buttons, so the global user-select:none rule killed
text selection. Right-click-to-react and click-to-edit also ate a live
highlight. Prefer selection when one exists.
get_custom_provider_extra_headers() was returning the result of
normalize_extra_headers() on the first matching base_url, even when
that entry had no extra_headers configured. A later providers.<name>
entry sharing the same URL but with headers set was therefore ignored.
Fix: store the normalized headers and only return when non-empty,
otherwise continue searching the remaining entries.
Fixes#74465
Verify that set_config_value and unset_config_value refuse to write
when config.yaml contains YAML syntax errors, and the original file
is left intact.
set_config_value() and unset_config_value() silently replaced the
entire config with an empty dict when config.yaml could not be
parsed. A single YAML syntax error would cause 'hermes config set'
to wipe all settings and write only the new key. Now exits with
error and preserves the existing file.
The first regeneration kept a ~5% transparent margin around the icon
plate (94.9% coverage). Windows expects the plate itself to be the icon
edge — scale the artwork's rounded plate to span the canvas exactly.