chore(chat): merge upstream for image-turn PR

Keep image-turn context tagging and upstream's typed steer-wrapper cleanup
in the same settlement path. Verify the combined recovery and streaming
behavior against current master.

Assisted-by: Hermes Agent:gpt-6-sol
This commit is contained in:
starship-s committed 2026-09-23 18:43:39 -06:00
commit 5d42c2acd2
52 files changed
+7013 -284

No files matched your search

+52 -2
View File
@@ -1020,6 +1020,29 @@ def _record_async_delegation_accepted(
)
def _wakeup_refusal_is_transient(resp: dict | None, status: int) -> bool:
"""True when the target refused the wake-up for a reason it will recover from.
Such refusals are not delivery failures of the completion itself, so they
must not consume its bounded durable delivery budget. Otherwise a burst of
refusals (e.g. ``agent_runtime_stale`` answers while the Agent runtime is
being replaced) terminally drops a completion whose origin session is alive:
* any payload that explicitly says ``retryable: True`` (stale runtime);
* the paused-wakeup contract (``process_wakeup_paused``);
* a session busy with another turn (``active_stream_id`` on a 409).
"""
if not isinstance(resp, dict):
return False
if resp.get("retryable") is True:
return True
if status != 409:
return False
if resp.get("error") == "process_wakeup_paused":
return True
return bool(resp.get("active_stream_id"))
def _start_async_delegation_wakeup_turn(
session_id: str,
wakeup_prompt: str,
@@ -1059,7 +1082,11 @@ def _start_async_delegation_wakeup_turn(
)
return
release_async_delegation_delivery(evt, claim)
transient = _wakeup_refusal_is_transient(resp, status)
if transient:
release_async_delegation_delivery(evt, claim, retryable=True)
else:
release_async_delegation_delivery(evt, claim)
_retry_unclaimed_async_delegation_event(
process_registry, evt, keep_legacy_retrying=True
)
@@ -1068,6 +1095,14 @@ def _start_async_delegation_wakeup_turn(
"async delegation wakeup paused for session %s; delivery remains retryable",
session_id,
)
elif transient:
logger.info(
"async delegation wakeup refused transiently for session %s "
"(status=%s err=%r); attempt refunded, durable retry scheduled",
session_id,
status,
(resp or {}).get("error"),
)
else:
logger.debug(
"async delegation wakeup not accepted for session %s: "
@@ -1848,7 +1883,22 @@ def recover_processes_for_webui(process_registry=None, get_session_fn=None) -> i
try:
proc_session = process_registry.get(process_id)
session_key = str(getattr(proc_session, "session_key", "") or "")
if not session_key or get_session_fn(session_key, metadata_only=True) is None:
if not session_key:
continue
# The session resolver (``api.models._resolve_session_once``)
# raises ``KeyError(sid)`` for a missing session — that is its
# documented contract for the "owner gone" outcome, not a
# fault. Treat it identically to a ``None`` return: skip the
# process without logging, and let a neighbouring live
# process still rebind. The narrow ``except KeyError`` lives
# INSIDE the outer try so an unrelated ``KeyError`` from
# ``process_registry.get()`` (or any routing-index work) still
# surfaces in the existing warning path.
try:
resolved = get_session_fn(session_key, metadata_only=True)
except KeyError:
continue
if resolved is None:
continue
except Exception:
logger.warning(
+26 -1
View File
@@ -11483,7 +11483,13 @@ _SETTINGS_DEFAULTS = {
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
"composer_control_order": [], # user-defined composer footer control order; invalid/duplicate keys are ignored
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
# #7622 (round 3): language is intentionally absent from the defaults so
# `load_settings()` reports `None` for a fresh install. This lets the
# client distinguish "no preference" from an explicit saved choice,
# so a user who genuinely picked English (and has `language: "en"`
# persisted on disk) is no longer overridden by the browser hint on
# their first hydration. When a user picks a locale in the Settings
# modal the value is written here and the field is set explicitly.
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
), # display name for the assistant
@@ -11723,6 +11729,16 @@ def load_settings() -> dict:
settings["default_model_provider"] = str(model_cfg.get("provider"))
except Exception:
logger.debug("Failed to resolve default model provider for settings")
# #7622/#7730 (round 4): keep the tri-state signal explicit. `language`
# is intentionally absent from `_SETTINGS_DEFAULTS` (see above), so
# without this line a fresh install's returned dict would OMIT the key
# entirely and the API payload would carry no `language` field. Emit an
# explicit `None` (serialized as JSON `null`) so the client receives the
# three-way signal it trusts: `null` = no preference, "en" = explicitly
# saved English, any other code = explicitly chosen locale. Stored
# values (including a legacy English pick written before this change)
# win via the merge above and are never touched here.
settings.setdefault("language", None)
return settings
@@ -11730,6 +11746,15 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
"password_hash",
"default_model",
"simplified_tool_calling",
} | {
# #7622 (round 3): `language` is intentionally absent from
# `_SETTINGS_DEFAULTS` so a fresh install returns `None` for
# `settings["language"]` and the client can distinguish "no
# preference" from an explicit saved choice. But the user
# MUST still be able to pick a locale in the Settings modal,
# so we add it back to the explicit allow-list here. The
# existing BCP-47 validation at save-time still applies.
"language",
}
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter", "shift+enter"},
+16
View File
@@ -38,6 +38,7 @@ from api.config import (
from api.helpers import _redact_text, redact_session_data
from api.models import clear_process_wakeup_pause, get_session, merge_session_messages_append_only
from api.run_journal import RunJournalWriter, bound_run_journal_snapshot_args
from api.turn_journal import append_turn_journal_event_for_stream
logger = logging.getLogger(__name__)
@@ -1419,6 +1420,21 @@ def _run_gateway_chat_streaming(
if cancel_event.is_set():
_restore_cancelled_success_writeback()
return
# #6366 re-gate: record the durable same-stream completion
# event in the crash-safe turn journal. The run journal's
# terminal state is only reached on its own ``stream_end``
# write path, so a Gateway run whose terminal write is lost
# would otherwise leave no completion evidence at all and
# stale-cancel recovery would re-append a duplicate
# recovered row after the valid final answer.
try:
append_turn_journal_event_for_stream(
session_id,
stream_id,
{"event": "completed", "created_at": time.time()},
)
except Exception:
logger.debug("Failed to append completed turn journal event", exc_info=True)
success_writeback_committed = True
try:
from api.goals import evaluate_goal_after_turn, has_active_goal
+38 -22
View File
@@ -203,6 +203,8 @@ class GatewayWatcher:
):
self._subscribers: list[queue.Queue] = []
self._sub_lock = threading.Lock()
# Final removal invalidates any projection admitted by an earlier cohort.
self._subscriber_epoch = 0
self._stop_event = threading.Event()
# Wakes a poll loop parked because nobody is subscribed (subscribe/stop).
self._idle_wake = threading.Event()
@@ -286,21 +288,32 @@ class GatewayWatcher:
self._idle_wake.set()
return q
def unsubscribe(self, q: queue.Queue):
"""Remove a subscriber queue."""
with self._sub_lock:
try:
self._subscribers.remove(q)
except ValueError:
pass
def _remove_subscriber_locked(self, q: queue.Queue) -> bool:
"""Remove a known queue under _sub_lock; fence polls on the last removal."""
try:
self._subscribers.remove(q)
except ValueError:
return False
if not self._subscribers:
self._subscriber_epoch += 1
self._last_cheap_fp = ''
self._last_full_projection_at = None
return True
def _notify_subscribers(self, sessions: list):
def unsubscribe(self, q: queue.Queue):
"""Remove a subscriber queue, invalidating projection on the last removal."""
with self._sub_lock:
self._remove_subscriber_locked(q)
def _notify_subscribers(self, sessions: list, *, epoch: int | None = None):
"""Push change event to all subscribers."""
event = {
'type': 'sessions_changed',
'sessions': sessions,
}
with self._sub_lock:
if epoch is not None and epoch != self._subscriber_epoch:
return # An old poll must not notify a new subscriber cohort.
dead = []
for q in self._subscribers:
try:
@@ -310,10 +323,7 @@ class GatewayWatcher:
except Exception:
dead.append(q)
for q in dead:
try:
self._subscribers.remove(q)
except ValueError:
pass
self._remove_subscriber_locked(q)
# Send a None sentinel so the SSE handler unblocks, closes,
# and lets the browser's EventSource auto-reconnect.
try:
@@ -330,9 +340,9 @@ class GatewayWatcher:
"""
with self._sub_lock:
has_subscribers = bool(self._subscribers)
admission_epoch = self._subscriber_epoch
if not has_subscribers:
# Force fresh projection on the next subscription, without touching DB.
self._last_full_projection_at = None
# Final removal already invalidated the cache under the same lock.
return False
db_path = self._state_db_path
# A watcher may start before the agent has created state.db. Publishing an
@@ -361,14 +371,20 @@ class GatewayWatcher:
if sessions is None:
return False
current_hash = _snapshot_hash(sessions)
if cheap_fp is not None:
self._last_cheap_fp = cheap_fp
self._last_full_projection_at = current_time
if current_hash != self._last_hash:
self._last_hash = current_hash
self._last_sessions = sessions
self._notify_subscribers(sessions)
with self._sub_lock:
if admission_epoch != self._subscriber_epoch or not self._subscribers:
return False # Never restore a cache invalidated during the DB read.
if cheap_fp is not None:
self._last_cheap_fp = cheap_fp
self._last_full_projection_at = current_time
if current_hash != self._last_hash:
changed = True
self._last_hash = current_hash
self._last_sessions = sessions
else:
changed = False
if changed:
self._notify_subscribers(sessions, epoch=admission_epoch)
return True
def _poll_loop(self):
+8
View File
@@ -505,6 +505,13 @@ def annotate_media_snapshots(
if allowed_predicate is None:
allowed_predicate = media_capture_allowed
media_re = _re.compile(r"MEDIA:([^\s\)\]]+)")
# #7680 re-gate (9/22): two-pass scan. First strip backtick
# wrappers (`` `MEDIA:path` `` → ``MEDIA:path``) so the bare
# class below does not consume the closing backtick as part of
# the path. Then the bare class (no backtick in the exclusion
# set) captures the full filename even when the path itself
# contains a backtick (e.g. ``report`final.png``).
backtick_re = _re.compile(r"`MEDIA:([^`\s]+)`")
captured = 0
for msg in messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
@@ -530,6 +537,7 @@ def annotate_media_snapshots(
if not text_parts:
continue
text = "\n".join(text_parts)
text = backtick_re.sub(lambda m: f"MEDIA:{m.group(1)}", text)
refs = media_re.findall(text)
if not refs:
continue
+405 -8
View File
@@ -1107,6 +1107,14 @@ _METADATA_PREFIX_MAX_BYTES = 1024 * 1024
#: even when the stop key sits at 2 KB.
_METADATA_PREFIX_FIRST_STAGE_BYTES = 64 * 1024
# Marks a sidecar as written by the CURRENT writer contract, where the
# persisted `message_count` equals len(messages) by construction (both keys
# land in the same atomic write). save()'s bounded-prefix shrink check trusts
# a persisted count ONLY when this marker sits next to it in the file; see
# _prefix_message_count(). Bump the value whenever the writer contract changes
# meaning, so counts from an older contract fall back to the full parse.
_MESSAGE_COUNT_MARKER = 1
def _read_metadata_json_prefix(path, max_prefix_bytes=_METADATA_PREFIX_MAX_BYTES):
"""Read only the metadata portion before the large arrays.
@@ -1546,6 +1554,16 @@ class Session:
# legacy-format reader that stops at a scene key still finds the count.
# The full anchor_activity_scenes bodies serialize AFTER messages.
meta['message_count'] = len(self.messages or [])
# _mc_v marks this file as written by the current writer contract,
# where `message_count` equals len(messages) by construction and both
# keys land in the same atomic write. save()'s shrink guard takes the
# bounded-prefix shortcut ONLY for marked files; an unmarked count
# (a legacy pre-#5854 sidecar, a sidecar materialized by an older
# recovery writer, any foreign writer) gets the full parse, so a stale
# count from outside this contract can never read a real shrink as a
# growth and skip the #1558 backup. One save re-marks the file, so the
# fast path still covers steady state.
meta['_mc_v'] = _MESSAGE_COUNT_MARKER
meta['anchor_scene_index'] = _anchor_scene_index_from_records(self.anchor_activity_scenes)
# Keep the in-memory fingerprint aligned with what we just persisted, so a
# later metadata-only reload of THIS object (or any fingerprint reader)
@@ -1558,7 +1576,7 @@ class Session:
meta['anchor_activity_scenes'] = self.anchor_activity_scenes if isinstance(self.anchor_activity_scenes, dict) else {}
# Fields not in METADATA_FIELDS (e.g. last_usage) go at the end. Exclude
# the keys we placed explicitly above so they aren't emitted twice.
_placed = {'message_count', 'anchor_scene_index', 'messages', 'tool_calls', 'anchor_activity_scenes'}
_placed = {'message_count', '_mc_v', 'anchor_scene_index', 'messages', 'tool_calls', 'anchor_activity_scenes'}
extra = {k: v for k, v in self.__dict__.items()
if k not in METADATA_FIELDS and k not in _placed
and not k.startswith('_')}
@@ -1578,12 +1596,46 @@ class Session:
# their .bak get restored automatically.
try:
if self.path.exists():
existing_text = self.path.read_text(encoding='utf-8')
try:
existing = json.loads(existing_text)
existing_msg_count = len(existing.get('messages') or [])
except (json.JSONDecodeError, ValueError):
existing_msg_count = -1 # corrupt → always back up
# The on-disk count, without reading the body.
#
# The decision below is a function of ONE integer -- how many
# messages the file on disk holds -- and save() already writes
# that integer into the metadata prefix, before `messages`, as
# `message_count` (see METADATA_FIELDS above; load_metadata_only
# and the sidebar freshness check read it the same way). So read
# THAT through a bounded 64 KiB prefix instead of the whole file.
# Measured before this: a 203,439,398-byte sidecar cost 20,377 ms
# (17,453 in read_text, 2,924 in json.loads) to yield one integer,
# on EVERY save -- including the grow-saves that never back
# anything up. The prefix read is O(64 KiB), and because the count
# is part of the bytes on disk it travels with any rewrite of them.
#
# An in-memory "I wrote this, stat says nothing changed" cache is
# NOT sufficient here, and was removed after review: (inode, size,
# mtime_ns) is not a content identity. A same-length in-place
# rewrite inside one mtime tick keeps all three fields -- ext4
# stamps mtime from a coarse clock, so two writes in the same tick
# share one mtime_ns -- and a stale cached count then reads a real
# shrink as a growth and skips the #1558 backup. The prefix count
# cannot be fooled that way.
#
# Every unknown falls through to the full read + parse below: a
# legacy (pre-#5854) sidecar whose count is not in the prefix, a
# count written without the current writer's _mc_v marker (an
# older writer's count can be stale relative to the messages
# array next to it), a corrupt or truncated prefix, a file with
# no top-level `messages` key at all, or metadata alone that
# overflows the budget.
# Fail-open is the contract -- never "assume no shrink".
existing_text = None
existing_msg_count = _prefix_message_count(self.path)
if existing_msg_count is None:
existing_text = self.path.read_text(encoding='utf-8')
try:
existing = json.loads(existing_text)
existing_msg_count = len(existing.get('messages') or [])
except (json.JSONDecodeError, ValueError):
existing_msg_count = -1 # corrupt → always back up
incoming_msg_count = len(self.messages or [])
if (
existing_msg_count > 0
@@ -1601,6 +1653,10 @@ class Session:
return
if existing_msg_count > incoming_msg_count:
bak_path = self.path.with_suffix('.json.bak')
if existing_text is None:
# The .bak body is the one thing that needs the full text,
# and a shrink is the one time it is needed.
existing_text = self.path.read_text(encoding='utf-8')
# SHOULD-FIX #2 (Opus): atomic write via tmp+replace,
# mirroring the main save() pattern below. Prevents a
# torn .bak from a crash mid-write or a concurrent
@@ -2483,6 +2539,191 @@ def _latest_user_matches_pending_text(messages, pending_text):
return False
def _pending_active_turn_token(session):
"""Return the exact WebUI active-turn token for the pending turn.
The token is ``"<stream_id>:<pending_started_at>"`` — the same value the
eager user-message checkpoint and the agent-result merge stamp onto the
materialized user row (``stamp_message_source``). It is unforgeable
per-turn identity: two distinct streams that submit the same prompt
inside the same second produce different tokens.
"""
from api.process_event_utils import build_active_turn_token
return build_active_turn_token(
getattr(session, 'active_stream_id', None),
getattr(session, 'pending_started_at', None),
)
def _transcript_user_row_is_pending_turn(message, session, pending_token) -> bool:
"""Return True only when a transcript user row provably IS the pending turn.
Identity is bound to the active-turn token, never to integer-second
timestamp equality: two different streams that send the same prompt
within one second truncate to the same ``int(timestamp)``, so the
checkpoint matcher alone would accept the *other* stream's row as the
pending turn. A token mismatch therefore always loses, and a row that
carries no token at all cannot be proven to be this turn — which means
the caller must recover, not suppress.
"""
if not isinstance(message, dict) or message.get('role') != 'user':
return False
row_token = message.get('_active_turn_token')
if pending_token:
if not row_token:
# Ambiguous legacy identity: the pending turn has a resolvable
# token but this row predates token stamping. We cannot prove it
# is the same turn, so recovery must run.
return False
return row_token == pending_token
if row_token:
# The row belongs to a different, token-bearing turn. Never let a
# same-second timestamp collision override that mismatch.
return False
# Neither side carries a token (legacy/dead-stream pending state): fall
# back to the strict content + timestamp + source + attachments
# checkpoint, which is the only identity signal available.
return _message_matches_pending_checkpoint(
message,
getattr(session, 'pending_user_message', None),
getattr(session, 'pending_started_at', None),
getattr(session, 'pending_user_source', None),
getattr(session, 'pending_attachments', None),
)
def _pending_turn_has_final_assistant_answer(messages, start_idx: int) -> bool:
"""Return True when the matched user turn ends in a genuine final answer.
Reuses the established final-answer semantics
(``_assistant_message_has_final_visible_text``) instead of "an assistant
row exists". An assistant row only settles the turn when it carries real
visible answer text: empty rows, tool-call-only rows, interim
``_partial`` progress rows, rows recovered from the run journal and
compaction reference cards are all rejected, so a turn that was
interrupted mid-tool-execution is never mistaken for a completed one.
The scan stops at the next real user row — a final answer that belongs
to a *later* turn must not settle this pending turn.
"""
from api.streaming import (
_assistant_message_has_final_visible_text,
_is_synthetic_control_message,
)
for later in messages[start_idx + 1:]:
if not isinstance(later, dict):
continue
if _is_synthetic_control_message(later):
continue
role = later.get('role')
if role == 'user':
if is_context_compression_marker(later):
# Synthetic compaction cards are not a user-turn boundary.
continue
return False
if role != 'assistant':
# Tool rows never prove a final answer.
continue
if (
later.get('_partial')
or later.get('_error')
or later.get('_recovered')
or later.get('_recovered_from_run_journal')
):
continue
if is_context_compression_marker(later):
continue
if _assistant_message_has_final_visible_text(later):
return True
return False
def _transcript_already_advanced_past_pending(session) -> bool:
"""#6366: a stale pending user message whose own user row already appears
in the durable transcript, followed by a genuine final assistant answer
inside that same user-turn boundary, means the transcript has already
advanced past this pending turn. Returning True here lets the recovery
path skip the recovered user / ``_partial`` clone / journal replay /
generic no-response error that would otherwise append after the valid
final answer. The tail-only check at line ~3485 (``session.messages[-1]``)
misses this case: when the pending turn's user row is older than
the transcript tail, the tail is a *newer* assistant row that
can never match the pending **user** checkpoint, and the recovery
path falls through into the append branch.
Identity is bound to the pending stream's exact active-turn token
(``_transcript_user_row_is_pending_turn``), so a repeated prompt from a
different stream — even one submitted in the same integer second —
can never be matched against this pending turn and silently dropped.
Completion requires a genuine final visible assistant answer
(``_pending_turn_has_final_assistant_answer``): empty, tool-call-only,
interim ``_partial``, journal-recovered and compaction tails all keep
recovery active, because that is precisely the interrupted-turn shape
the recovery path exists for.
The transcript heuristic alone is not durable: an unflagged interim
prose row ("Let me check the logs first.") followed by tool rows that
were never followed by a final answer in the transcript passes the
predicate as a "completed" turn, but the stream may have died mid-
tool and the run journal was never marked done. 9/23 re-gate: also
require durable same-stream terminal evidence. The run journal
cannot supply that evidence on this path — the caller in
``_apply_core_sync_or_error_marker`` already returns early whenever
the run journal reports ``completed`` for the stream, so the branch
that reaches this predicate can never observe that state (which is
why the previous run-journal gate was unreachable dead code). The
evidence therefore has to come from the turn journal, which records
the exact-stream ``completed`` event for the pending turn
(``_turn_journal_records_completion``). Without it, fall through to
the normal journal-recovery path so the partial output is replayed
and the interruption is marked.
The suppression is therefore deliberately conservative: whenever the
turn's identity, its completion, or its durable terminal evidence
cannot be positively proven, this helper returns False and the
prompt is recovered instead of discarded. A false negative leaves a
visible cosmetic duplicate; a false positive silently destroys a
prompt or a response.
Idempotent: this is a pure read that does not mutate the session.
Running recovery twice therefore produces the same outcome.
"""
pending_text = getattr(session, 'pending_user_message', None)
if not pending_text:
return False
messages = getattr(session, 'messages', None)
if not isinstance(messages, list):
return False
pending_token = _pending_active_turn_token(session)
for idx, message in enumerate(messages):
if not _transcript_user_row_is_pending_turn(message, session, pending_token):
continue
# Found the pending turn's own user row at ``idx``. A genuine
# final answer inside that turn's boundary is necessary but not
# sufficient: durable same-stream terminal evidence must also
# exist, otherwise unflagged interim prose
# ("Let me check the logs first.") followed by tool rows can be
# mistaken for a finished turn.
if not _pending_turn_has_final_assistant_answer(messages, idx):
continue
stream_id = getattr(session, 'active_stream_id', None)
# The turn journal — not the run journal — carries the
# completion evidence that is still observable here: the caller
# (``_apply_core_sync_or_error_marker``) already consumed the
# run-journal ``completed`` state on its own early return, so a
# run-journal gate at this depth can never fire.
if not _turn_journal_records_completion(session, stream_id):
# No durable same-stream terminal evidence — the turn may
# have died mid-tool. Fall through to the journal-recovery
# path instead of suppressing it.
return False
return True
return False
def _partial_message_signature(message: dict) -> tuple:
"""Return a stable identity for partial assistant markers recovered on load."""
if not isinstance(message, dict):
@@ -2684,6 +2925,54 @@ def _run_journal_terminal_state(session, stream_id: str | None) -> str | None:
return str(terminal.get('terminal_state') or '') or None
def _turn_journal_records_completion(session, stream_id: str | None) -> bool:
"""Return True when the crash-safe turn journal proves the turn for
``stream_id`` reached its terminal ``completed`` state.
The turn journal is written per stream/turn id by the exact-stream
workers and by :mod:`api.routes` on submission, so a ``completed``
event for the *pending stream id* is completion evidence that
survives a run-journal write failure: the run journal only reaches
``done`` / ``stream_end`` on its own write path, and a process that
dies between the turn journal write and the run-journal terminal
append leaves the run journal without terminal state while the turn
journal still records that the turn actually finished.
Bound strictly to ``stream_id`` — a ``completed`` turn belonging to
another stream of the same session never settles this one — and to
the latest turn recorded for that stream, so a turn that was
cancelled or crashed (``interrupted``) keeps recovery active even
when an earlier turn of the same stream completed normally.
"""
if not stream_id:
return False
try:
from api.turn_journal import (
derive_turn_journal_states,
read_turn_journal,
)
journal = read_turn_journal(session.session_id)
states, _ = derive_turn_journal_states(journal.get('events') or [])
except Exception:
return False
latest: tuple[float, str, dict] | None = None
for turn_id, event in states.items():
if str(event.get('stream_id') or '') != str(stream_id):
continue
try:
created_at = float(event.get('created_at') or 0)
except (TypeError, ValueError):
created_at = 0.0
# ``>=`` on both elements keeps the winner deterministic when two
# turns of the same stream share a ``created_at``.
candidate = (created_at, turn_id, event)
if latest is None or candidate[:2] >= latest[:2]:
latest = candidate
if latest is None:
return False
return latest[2].get('event') == 'completed'
def _recoverable_unsaved_gateway_terminal_error(
session,
stream_id: str | None,
@@ -3560,6 +3849,29 @@ def _apply_core_sync_or_error_marker(
)
return True
if not _tail_user_already_checkpointed:
# #6366 re-gate: when the durable transcript has already
# advanced past this pending turn into a newer settled
# user/assistant boundary, the recovery path must NOT
# append a recovered user row + ``_partial`` clone +
# journal replay + generic no-response error after the
# valid final answer. The tail-only check above misses
# that case (the tail is a newer assistant row that can
# never match the pending user checkpoint). Clear only
# the stale pending fields and return; the transcript is
# already correct and durable.
if _transcript_already_advanced_past_pending(session):
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.pending_user_source = None
session.save(touch_updated_at=touch_updated_at)
logger.info(
"Session %s: cleared stale pending state for stream %s — transcript already advanced past this turn",
sid,
_stream_id,
)
return True
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
else:
recovered = {
@@ -4342,6 +4654,57 @@ def _sidecar_stat_signature(path):
int(st.st_size), int(getattr(st, 'st_ctime_ns', int(st.st_ctime * 1_000_000_000))))
_MESSAGE_COUNT_MARKER = 1
def _prefix_message_count(path):
"""The sidecar's own persisted ``message_count``, from a bounded prefix.
The #1558 shrink check in save() needs exactly one integer -- how many
messages the file on disk holds -- and the writer puts that integer in the
metadata prefix, before ``messages``, precisely so readers can have it
without parsing the body (see METADATA_FIELDS). Reading the prefix costs
O(64 KiB) against O(file size) for ``read_text`` + ``json.loads`` (measured
2026-09-15: 20,377 ms for one integer on a 203,439,398-byte sidecar).
Content-derived on purpose. A stat identity cannot stand in for it: an
in-place rewrite of the same length inside one mtime tick keeps inode, size
*and* mtime_ns, so a cached count would read a real shrink as a growth and
skip the backup. The count is part of the bytes, so any rewrite of them
rewrites it too.
Gated on the writer marker (``_mc_v``, review 2026-09-22): a count is only
trusted when the SAME write that produced the messages array also vouched
for the count. Anything unmarked -- a legacy pre-#5854 sidecar, a sidecar
materialized by an older recovery writer whose denormalized count could be
stale against its rows, any foreign writer -- returns None and the caller
falls back to the full read + parse. After one save by the current writer
the file carries the marker and the fast path resumes.
Returns None -- meaning "the caller must fall back to the full read +
parse" -- for every shape that does not carry a usable MARKED count: an
unmarked or wrong-marker file, a legacy sidecar whose count is not in the
prefix, a file with no top-level ``messages`` key at all, a corrupt or
truncated prefix, an unreadable path, or metadata alone that overflows the
budget.
"""
try:
raw = _read_metadata_json_prefix(path)
except (OSError, ValueError):
return None
if not raw:
return None
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
if parsed.get('_mc_v') != _MESSAGE_COUNT_MARKER:
return None
return _parse_nonnegative_int(parsed.get('message_count'))
def _legacy_sidecar_facts_get(sid):
"""Return cached authoritative facts for a LEGACY sidecar, or None (#5854).
@@ -4891,7 +5254,41 @@ class _FullSessionResolveRequired(RuntimeError):
"""Internal signal that a full sidecar load must enter the bounded path."""
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = 2
def _read_max_session_resolve_concurrent() -> int:
"""#7421: env override for the heavy session-resolve cap. The
hardcoded literal 2 is too low for high-concurrency
deployments where several parallel active sessions all need
a full-transcript resolve; operators can set
``HERMES_WEBUI_MAX_SESSION_RESOLVE`` to a positive int to
raise the cap without code changes. A bad or missing value
falls back to the previous default of 2, so an operator who
sets ``HERMES_WEBUI_MAX_SESSION_RESOLVE=0`` or a non-numeric
value does not regress to a blocked or unbounded state.
The cap is a *safety bound* (per-resolve cost tracked in
#7310), not a user preference — a typo like ``=999999``
must fall back to the safe default, never to the permissive
extreme. Every other bad input in this function falls back
to ``2``; out-of-range values do too. The previously
accepted upper bound (64) is reachable only via an explicit
in-range operator value; a profile's ``.env`` cannot reach
it at all (see ``_PROTECTED_ENV_KEYS``).
"""
raw = (os.getenv("HERMES_WEBUI_MAX_SESSION_RESOLVE") or "").strip()
if not raw:
return 2
try:
n = int(raw)
except (TypeError, ValueError):
return 2
if n < 1:
return 2
if n > 64:
return 2 # #7656 round-3: typo-safe fallback, not clamp-up-to-64
return n
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = _read_max_session_resolve_concurrent()
_FULL_SESSION_RESOLVE_SLOTS = threading.BoundedSemaphore(
_FULL_SESSION_RESOLVE_MAX_CONCURRENT
)
+33 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
import inspect
import logging
import math
import re
@@ -451,16 +452,46 @@ def complete_async_delegation_delivery(
_mark_legacy_async_delivery_complete(claim.delegation_id)
def _release_accepts_retryable(release_fn: Any) -> bool:
"""Whether the core ``release_event_delivery`` supports ``retryable=``.
Older Agent cores predate the keyword; calling them with it would raise
``TypeError`` and skip the release entirely.
"""
try:
params = inspect.signature(release_fn).parameters
except (TypeError, ValueError):
return False
if "retryable" in params:
return params["retryable"].kind in (
inspect.Parameter.KEYWORD_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
def release_async_delegation_delivery(
evt: Any,
claim: AsyncDelegationDeliveryClaim,
*,
retryable: bool = False,
) -> None:
"""Release a failed claim so a later WebUI consumer can retry it."""
"""Release a failed claim so a later WebUI consumer can retry it.
``retryable=True`` tells the core the refusal was transient, so the claimed
attempt is refunded instead of counting against the bounded delivery
budget (``tools.async_delegation.release_event_delivery(...,
retryable=True)``). A core that predates the keyword gets a plain release:
the attempt is then consumed as before, never an exception.
"""
try:
if claim.durable:
from tools.async_delegation import release_event_delivery
release_event_delivery(evt, claim.claim_id)
if retryable and _release_accepts_retryable(release_event_delivery):
release_event_delivery(evt, claim.claim_id, retryable=True)
else:
release_event_delivery(evt, claim.claim_id)
except Exception:
logger.warning(
"Failed to release durable async delegation delivery for %s",
+14
View File
@@ -202,6 +202,14 @@ _PROTECTED_ENV_KEYS = frozenset({
'HERMES_WEBUI_ISOLATED_PROFILE',
# Server-wide sidebar window; a profile .env must not widen the per-request query.
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
# #7656 round-3: HERMES_WEBUI_MAX_SESSION_RESOLVE is a process-wide resource
# cap (controls the BoundedSemaphore that gates full-transcript resolves).
# A profile's .env is per-profile by design; allowing it to set a
# server-wide limit (and a first-load-wins asymmetry) would let a
# single profile drag the rest into a tighter or looser posture than
# the operator intended. Same shape as the isolated-profile key: only
# the operator/launcher env at startup can set it.
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
})
@@ -939,6 +947,12 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
# profile's own env on any runtime/gateway-parity path.
'HERMES_WEBUI_ISOLATED_PROFILE',
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
# #7656 round-3: process-wide resolve cap. Filter out a profile's
# .env override the same way we filter the isolated-profile key, so
# the cap stays at the operator/launcher value across profile
# switches rather than re-sizing to whatever the first-loaded
# profile env set.
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
}
+171 -8
View File
@@ -4067,6 +4067,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
"tool_calls": tool_calls,
"last_assistant_text": assistant_text,
"last_reasoning_text": reasoning_text,
"runtime_model": runtime_model_from_events(session_id, stream_id, events),
"activity_burst_anchors": activity_burst_anchors,
"current_activity_burst_id": current_activity_burst_id,
"current_live_segment_seq": current_live_segment_seq,
@@ -7183,7 +7184,35 @@ def _context_length_lookup_inputs_for_model(
if not effective_provider:
effective_provider = _canonical_context_provider(model_cfg.get("provider"))
if not effective_base_url:
effective_base_url = str(model_cfg.get("base_url") or "").strip()
# #7535: the global model.base_url may only fill an empty slot when
# the session provider IS the configured model.provider owner
# (mirror the ownership predicate used for model_cfg's API key in
# _context_length_config_api_key_for_provider). A built-in registry
# provider (empty base_url by design) must keep the slot empty so
# the registry endpoint resolves instead of another provider's URL.
#
# Two shapes cannot own the slot and therefore cannot conflict, so
# they keep master's backfill: a config that declares no provider
# at all (the profile-setup path writes model.base_url without one)
# and the two spellings of the same built-in id (opencode_go ==
# opencode-go), folded through api.config._canonicalise_provider_id
# so distinct custom:* slugs stay distinct.
_model_cfg_provider = _canonical_context_provider(model_cfg.get("provider"))
_owner_provider = _model_cfg_provider
_session_provider = effective_provider
try:
from api.config import _canonicalise_provider_id as _canon_provider_id
_owner_provider = _canon_provider_id(_owner_provider) or _owner_provider
_session_provider = _canon_provider_id(_session_provider) or _session_provider
except Exception:
pass
if (
not effective_provider
or not _model_cfg_provider
or _providers_match_for_context(_owner_provider, _session_provider)
):
effective_base_url = str(model_cfg.get("base_url") or "").strip()
custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else None
if not isinstance(custom_providers, list):
@@ -10866,6 +10895,7 @@ from api.streaming import (
)
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
from api.run_journal import (
runtime_model_from_events,
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
_summary_from_events,
bound_run_journal_snapshot_args,
@@ -20678,6 +20708,16 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")
# #7680 re-gate (9/22): two-pass scan.
# 1. `` `MEDIA:path` `` (backtick-wrapped, inline-code form) → strip
# the wrapping backticks so the bare-token pass below sees a
# plain ``MEDIA:path`` and the closing backtick is not consumed
# as part of the path.
# 2. ``MEDIA:[^\s\)\]]+`` (bare, no backtick in the exclusion
# class) so a filename that legally contains a backtick
# (``report`final.png``) is captured in full instead of being
# truncated at the first backtick.
_BACKTICK_MEDIA_RE = re.compile(r"`MEDIA:([^`\s]+)`")
def _message_content_text(content) -> str:
@@ -20737,6 +20777,10 @@ def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[
)
if "MEDIA:" not in text:
continue
# #7680 re-gate: strip backtick wrappers first so the bare
# class below captures the full path even when the filename
# itself contains a backtick.
text = _BACKTICK_MEDIA_RE.sub(lambda m: f"MEDIA:{m.group(1)}", text)
for ref in _MEDIA_TOKEN_RE.findall(text):
if "://" in ref:
continue
@@ -21929,6 +21973,99 @@ def _handle_live_models(handler, parsed):
_env = str(_cp.get("key_env") or "").strip()
return os.getenv(_env, "").strip() if _env else ""
def _custom_provider_models_discover_is_false(_cp):
"""True when ``discover_models`` is an explicit ``false`` opt-out.
Mirrors ``api.config._provider_discover_allowed`` / Hermes
Agent ``model_switch_providers._discover_flag``: ``discover_models``
defaults to True, and the string forms ``"false"``/``"no"``/``"0"``
(case-insensitive) mean False. Used to decide whether a
dict-shaped ``models`` mapping is a hand-pinned allowlist (only
when discovery is off) rather than per-model metadata.
"""
_discover = _cp.get("discover_models", True) if isinstance(_cp, dict) else True
if isinstance(_discover, str):
return _discover.strip().lower() in {"false", "no", "0"}
return not bool(_discover)
def _custom_provider_allowlist_ids(_cp):
"""Plural ``models`` list only — the explicit allowlist signal.
The singular ``model`` field is sticky/default metadata, NOT an
allowlist: it must not gate live-catalog filtering, otherwise a
provider configured with only ``model: assistant`` (no ``models``
list) would be collapsed to a single model. Only an explicit
``models`` allowlist expresses "show exactly these models".
An auto-discovered catalog is NOT an allowlist: when Hermes
persisted discovery results back into config (``models: {...}``
plus ``models_discovered: true``), that mapping is a snapshot
of what the gateway exposed at discovery time. Gating on it
would permanently pin the live catalog to the first-discovery
set, silently dropping any model the user pulls in later
(LM Studio / Ollama). ``_provider_models_are_discovered_catalog()``
is the shared predicate the ``/api/models`` path already uses;
when it says "discovered", return no allowlist and let the
live probe win. An explicit ``discover_models: false`` opt-out
re-pins the catalog (the predicate accounts for it), so a
hand-pinned discovered catalog still filters.
A dict-shaped ``models`` mapping that is NOT marked discovered is
*per-model metadata* written by the Hermes Agent setup flow
(``hermes_cli/model_switch.py::_save_custom_provider`` and the
setup wizard) — e.g. ``{chat-a: {context_length: 128000}}``. It is
not a catalog narrow: treating its keys as an allowlist would
collapse the live picker to the single saved default (keyless
Ollama) while the CLI live-probe shows the full catalog. Only an
explicit ``discover_models: false`` opts into treating the dict
keys as a pinned allowlist. List/JSON-array-string/Python-literal
shapes remain plain allowlists.
The plural value is decoded through ``_parse_config_string_list()``
because ``hermes config set`` / JSON-mode editor saves persist
lists as quoted JSON-array strings (``'["chat-a","chat-b"]'``) or
Python literals (``"['chat-a']"``). Handling only ``dict``/``list``
made a serialized allowlist fall through to ``[]``, which the
caller reads as "no allowlist configured" and floods the picker
with the full upstream catalog — the same class of bug as the
``skills.disabled`` regression (#7120 / #7134).
"""
from api.config import _provider_models_are_discovered_catalog
if _provider_models_are_discovered_catalog(_cp):
return []
_ids = []
_models = _cp.get("models")
# Serialized shapes only: ``hermes config set`` / JSON-mode
# editor saves persist lists as quoted JSON-array strings
# (``'["chat-a","chat-b"]'``) or Python literals
# (``"['chat-a']"``). Decode those through the shared helper so
# they are honored; native dict/list values are walked below so
# dict *entries* keep their id|model|name metadata (the decoder
# str()-ifies list members, which would mangle them).
if isinstance(_models, str):
_models = _parse_config_string_list(_models)
if isinstance(_models, dict):
# A dict-shaped ``models`` is per-model metadata written by
# the Hermes Agent setup flow, NOT a catalog narrow. Only a
# ``discover_models: false`` opt-out treats the dict keys as a
# pinned allowlist; otherwise return the empty allowlist so the
# live probe returns the full catalog.
if not _custom_provider_models_discover_is_false(_cp):
return []
for _mid in _models:
if isinstance(_mid, str) and _mid.strip():
_ids.append(_mid.strip())
elif isinstance(_models, (list, tuple)):
for _item in _models:
if isinstance(_item, str) and _item.strip():
_ids.append(_item.strip())
elif isinstance(_item, dict):
_mid = _item.get("id") or _item.get("model") or _item.get("name")
if _mid and str(_mid).strip():
_ids.append(str(_mid).strip())
return _ids
# For 'custom' and 'custom:*' providers, provider_model_ids()
# returns [] because they aren't real hermes_cli endpoints.
# Fall back to the custom_providers entries from config.yaml so
@@ -21937,11 +22074,13 @@ def _handle_live_models(handler, parsed):
# Collect config-specified model IDs separately so they don't
# prevent the live fetch below from running (#3718).
_config_ids = []
_allowlist_ids = []
if provider == "custom" or provider.startswith("custom:"):
for _cp in _custom_provider_entries_for_request():
if custom_provider_entry is None:
custom_provider_entry = _cp
_config_ids.extend(_custom_provider_model_ids(_cp))
_allowlist_ids.extend(_custom_provider_allowlist_ids(_cp))
# Always try live fetch for custom providers — config entries are a
# fallback, not a replacement. The live endpoint should return ALL
@@ -22012,15 +22151,39 @@ def _handle_live_models(handler, parsed):
except Exception as _fetch_err:
logger.debug("Live fetch from custom provider failed: %s", _fetch_err)
# If live fetch succeeded, merge with config entries (live takes
# priority). If live fetch failed, fall back to config-only list.
# If live fetch succeeded, filter the live catalog down to the
# config-declared models allowlist first, then append any
# allowlisted models the live endpoint didn't return. Custom
# providers (esp. New-API-style gateways) expose their ENTIRE
# catalog via /v1/models — including image/audio models that
# are not chat models. The picker must not surface models the
# user never declared in config.yaml. Only a NON-EMPTY explicit
# ``models`` allowlist gates the filter — a provider configured
# with just a singular ``model`` (no ``models`` list) keeps the
# unfiltered live catalog. When no allowlist is configured,
# return the live list as-is (preserves the pre-filter
# discovery behaviour).
#
# An empty allowlist (``models: []``, ``models: "[]"``, or a
# value that decodes to no usable ids) is deliberately treated
# as "not configured", NOT as "allow nothing". Gating on
# declared-ness instead would emit an EMPTY picker and make the
# provider unselectable — a harder failure than surfacing a few
# extra models, and unrecoverable from the UI because
# ``custom_providers`` is hand-edited in config.yaml (the WebUI
# has no write path for it). ``[]`` in practice means a
# leftover/placeholder key, not an intentional deny-all, and no
# deny-all use case exists: a provider the user wants hidden is
# removed from ``custom_providers`` outright.
if ids:
_live_set = set(ids)
for _cid in _config_ids:
if _cid not in _live_set:
ids.append(_cid)
if _allowlist_ids:
_allowlist_set = set(_allowlist_ids)
ids = [m for m in ids if m in _allowlist_set] or []
for _aid in _allowlist_ids:
if _aid not in ids:
ids.append(_aid)
else:
ids = list(_config_ids)
ids = list(_allowlist_ids or _config_ids)
# ── OpenAI-compat live fetch fallback ──────────────────────────────────
# When provider_model_ids() is unavailable or returns [] for a provider
+35 -2
View File
@@ -10,6 +10,7 @@ import os
import re
import threading
import time
from copy import deepcopy
from collections import OrderedDict
from pathlib import Path
from typing import Any, Iterable
@@ -130,7 +131,7 @@ def _get_cached_summary(path: Path) -> dict | None:
_SUMMARY_CACHE.pop(key, None)
return None
_SUMMARY_CACHE.move_to_end(key)
return dict(summary)
return deepcopy(summary)
def _cache_summary(
@@ -147,7 +148,7 @@ def _cache_summary(
return
key = str(path)
with _SUMMARY_CACHE_LOCK:
_SUMMARY_CACHE[key] = (signature, dict(summary))
_SUMMARY_CACHE[key] = (signature, deepcopy(summary))
_SUMMARY_CACHE.move_to_end(key)
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
_SUMMARY_CACHE.popitem(last=False)
@@ -534,6 +535,37 @@ def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
)
def runtime_model_from_events(session_id: str, stream_id: str, events: Iterable[dict]) -> dict | None:
"""Project only observed serving identity for this journal owner.
A fallback warning or malformed later observation invalidates old evidence;
the configured selection and free-text status never supply serving identity.
"""
observed = None
for event in events:
if not isinstance(event, dict) or event.get("session_id") != session_id or event.get("run_id") != stream_id:
continue
payload = event.get("payload")
if not isinstance(payload, dict):
if event.get("event") == "runtime_model":
observed = None
continue
if event.get("event") == "warning" and payload.get("type") == "fallback":
observed = None
elif event.get("event") == "runtime_model":
observed = None
if (payload.get("session_id") != session_id or payload.get("stream_id") != stream_id
or not isinstance(payload.get("model"), str) or not payload["model"].strip()
or not isinstance(payload.get("fallback_active"), bool)
or payload.get("phase") not in ("observed_output", "route_observed")):
continue
observed = {key: payload[key] for key in (
"session_id", "stream_id", "model", "fallback_active", "phase")}
if isinstance(payload.get("provider"), str) and payload["provider"].strip():
observed["provider"] = payload["provider"]
return observed
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
ordered = [event for event in events if isinstance(event, dict)]
last = ordered[-1] if ordered else None
@@ -549,6 +581,7 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
"terminal": bool(terminal),
"terminal_state": status,
"last_event": (last or {}).get("event"),
"runtime_model": runtime_model_from_events(session_id, run_id, ordered),
}
+8 -1
View File
@@ -601,7 +601,14 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
'session_id': row.get('id'),
'title': row.get('title') or 'Recovered WebUI Session',
'workspace': workspace_value if isinstance(workspace_value, str) else '',
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
# state.db is canonical for the recovered message rows. Its denormalized
# sessions.message_count is absent from older schemas (read above as 0)
# and can be stale after recovery, so never copy it into a sidecar whose
# `messages` array we just materialized. Session.save() uses this field
# to decide whether an overwrite shrinks the transcript; it must match
# the payload exactly or the #1558 backup / empty-snapshot guards can be
# bypassed.
'message_count': len(messages if isinstance(messages, list) else []),
'worktree_path': row.get('worktree_path') or None,
'worktree_branch': row.get('worktree_branch') or None,
'worktree_repo_root': row.get('worktree_repo_root') or None,
+186 -1
View File
@@ -895,10 +895,39 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
or 'falling back' in m
or 'fallback activated' in m
or 'trying fallback' in m
or 'model fallback:' in m
or 'switched to fallback' in m
or 'primary model restored:' in m
)
)
# Session turn-lease notices emitted by the Agent (agent/turn_facade_lease.py)
# while another Hermes process (gateway, CLI, cron) holds this session's turn
# lease. Emitted via ``_emit_status`` (kind ``lifecycle``) while waiting and on
# admission, and via ``_emit_warning`` (kind ``warn``) when the wait times out
# and the message was not processed.
_SESSION_LEASE_WAIT_MARKERS = (
'another hermes process is using this session',
'still waiting for the other hermes process',
'another hermes process kept this session busy',
'session is free; loading the latest transcript',
)
def _is_session_lease_wait_message(kind: str, message: str) -> bool:
"""Return True for Agent session turn-lease wait notices.
Classification keys on the Agent status kind (``lifecycle`` / ``warn``) so
user-authored text can never be promoted to a warning.
"""
k = str(kind or '').strip().lower()
if k not in ('lifecycle', 'warn'):
return False
m = str(message or '').strip().lower()
return any(marker in m for marker in _SESSION_LEASE_WAIT_MARKERS)
def _is_agent_compression_start_status(kind: str, message: str) -> bool:
"""Return True only for real Hermes context-compression start notices.
@@ -2351,6 +2380,14 @@ def _prepare_marker_clean_writeback(
cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance(
result_messages
)
# Same internal-control class, second home: a consumed mid-turn /steer is
# appended to the turn's last tool result wrapped in
# [OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]. Strip it
# here, on the rows both writebacks are built from, so neither
# session.messages (rendered verbatim) nor session.context_messages keeps
# the raw wrapper. Stripping the incoming rows too keeps them identity-equal
# to the marker-free rows persisted by earlier turns. (#7600)
cleaned = _strip_oob_markers_from_messages(cleaned)
provenance = {
'verification_nudge_seen': has_verification_nudge,
'active_turn_identity': copy.deepcopy(active_turn_identity),
@@ -2502,6 +2539,11 @@ def _settle_result_messages(
)
):
context_user[_WEBUI_TRUSTED_AGENT_INPUT_FIELD] = trusted_agent_input
# The merge carries earlier display rows across turns verbatim, so a row
# settled before this guard existed would keep its raw wrapper forever.
# Scrub the persisted display copy too — after the merge, so identity
# matching above still saw the rows unchanged. (#7600)
session.messages = _strip_oob_markers_from_messages(session.messages)
_annotate_media_snapshots_for_settled_messages(session.messages)
_compact_session_image_parts_for_persistence(session)
_advance_truncation_watermark_after_commit(session) # #3831
@@ -5719,6 +5761,94 @@ def _strip_oob_blocks(content):
return content
_OOB_ANY_OPEN_RE = re.compile(
r'\[OUT-OF-BAND\s+USER\s+MESSAGE(?:\s*(?:—|-)\s*.*?)?\]',
re.IGNORECASE,
)
_OOB_ANY_CLOSE_RE = re.compile(
r'\[/OUT-OF-BAND\s+USER\s+MESSAGE\]',
re.IGNORECASE,
)
def _unwrap_single_oob_frame(content: str) -> str | None:
"""Unwrap exactly one fully-validated [OUT-OF-BAND USER MESSAGE] frame.
Returns the extracted inner user text if and only if ``content`` consists of
exactly one valid opening tag and one valid closing tag wrapping the user
content. If markers are multiple, nested, incomplete, or ambiguous, returns
None so caller preserves the row byte-for-byte.
"""
if not isinstance(content, str):
return None
stripped = content.strip()
if not stripped:
return None
open_matches = list(_OOB_ANY_OPEN_RE.finditer(stripped))
close_matches = list(_OOB_ANY_CLOSE_RE.finditer(stripped))
# Must have exactly one opening marker and one closing marker
if len(open_matches) != 1 or len(close_matches) != 1:
return None
open_m = open_matches[0]
close_m = close_matches[0]
# Opening marker must be at the very start of stripped content
if open_m.start() != 0:
return None
# Closing marker must be at the very end of stripped content
if close_m.end() != len(stripped):
return None
# Opening marker must end before closing marker starts
if open_m.end() > close_m.start():
return None
inner = stripped[open_m.end():close_m.start()]
# Strip surrounding whitespace/newlines from the extracted user text
return inner.strip('\r\n').strip()
def _unwrap_steer_row_oob_marker(message: dict) -> None:
"""Extract inner steer text from a typed steer row in place (#7600).
The Hermes Agent emits mid-turn steers as standalone typed user rows
(`role == 'user'`, `display_kind == 'steer'`). The control wrapper
`[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]` is
extracted to preserve only the user-authored instruction.
Mutates caller-row in place to maintain object identity. If the marker
frame is malformed, nested, multiple, or legacy, preserves the row
byte-for-byte.
"""
if not isinstance(message, dict):
return
if message.get('role') != 'user' or message.get('display_kind') != 'steer':
return
content = message.get('content')
if isinstance(content, str):
unwrapped = _unwrap_single_oob_frame(content)
if unwrapped is not None:
message['content'] = unwrapped
elif isinstance(content, list):
if len(content) == 1 and isinstance(content[0], dict):
part = content[0]
if part.get('type') == 'text' and isinstance(part.get('text'), str):
unwrapped = _unwrap_single_oob_frame(part['text'])
if unwrapped is not None:
part['text'] = unwrapped
def _strip_oob_markers_from_messages(messages):
"""Unwrap OOB steer markers from typed steer rows in place (#7600)."""
for message in messages or []:
_unwrap_steer_row_oob_marker(message)
return messages
def _content_has_reasoning_only_parts(content) -> bool:
if not isinstance(content, list) or not content:
return False
@@ -10027,6 +10157,37 @@ def _run_agent_streaming(
except Exception:
logger.debug("Failed to put event to queue")
_last_runtime_model_identity = None
_runtime_model_session_id = session_id
def _observe_runtime_model():
"""Publish this turn's Agent identity at output, never at an attempted route."""
nonlocal _last_runtime_model_identity
raw_model = getattr(agent, 'model', None)
if not isinstance(raw_model, str) or not raw_model.strip():
return
raw_provider = getattr(agent, 'provider', None)
provider = str(raw_provider).strip().lstrip('@').lower() if isinstance(raw_provider, str) else ''
# The Agent may carry a provider-qualified routing hint in its model.
from api.config import _parse_provider_qualified_model_id
parsed = _parse_provider_qualified_model_id(raw_model.strip())
model_id = (parsed[0] if parsed else raw_model).strip()
if not model_id:
return
fallback_active = getattr(agent, '_provider_fallback_active', None) is True
identity = (provider, model_id.lower(), fallback_active)
if identity == _last_runtime_model_identity:
return
payload = {
'session_id': _runtime_model_session_id, 'stream_id': stream_id,
'model': model_id, 'fallback_active': fallback_active,
'phase': 'observed_output',
}
if provider:
payload['provider'] = provider
put('runtime_model', payload)
_last_runtime_model_identity = identity
# #5940: capture a terminal (non-retryable) provider error the Agent emits via
# its lifecycle status_callback. The Agent aborts a non-retryable API error
# (e.g. HTTP 400 "invalid model / no credentials") with
@@ -10047,6 +10208,7 @@ def _run_agent_streaming(
turn-completion classifier can report the real cause instead of the
generic no_response fallback. All other lifecycle messages are dropped.
"""
nonlocal _last_runtime_model_identity
_message = str(message or '').strip()
_kind = str(kind or '').strip().lower()
if not _message:
@@ -10069,8 +10231,14 @@ def _run_agent_streaming(
return
# Pass through rate-limit and fallback messages so the frontend can
# show them as warnings via the existing messages.js 'warning' listener.
# Session turn-lease waits (another Hermes process owns this session)
# use the same channel so a delayed turn explains itself.
if _is_session_lease_wait_message(_kind, _message):
put('warning', {'type': 'session_lease_wait', 'message': _message})
return
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
if _is_fallback_notice:
_last_runtime_model_identity = None
put('warning', {'type': 'fallback', 'message': _message})
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
@@ -10618,6 +10786,8 @@ def _run_agent_streaming(
nonlocal _token_sent
if text is None:
return # end-of-stream sentinel
if text:
_observe_runtime_model()
# #4729: visible output is starting — flush any buffered reasoning tail
# first so the live Thinking stream is complete before/at the transition.
_flush_reasoning_buffer()
@@ -10661,6 +10831,8 @@ def _run_agent_streaming(
# partial window is not lost when the reasoning phase ends.
_flush_reasoning_buffer()
return
if text:
_observe_runtime_model()
_tool_boundary_advanced = False
reasoning_delta = str(text)
# Some runtimes mirror user-visible progress text through the
@@ -11873,6 +12045,11 @@ def _run_agent_streaming(
if isinstance(_m, dict) and _m.get('role') == 'assistant':
_answer = str(_m.get('content', ''))
break
if (_answer.strip() and not result.get('error')
and not _agent_result_terminal_failure(result)
and not getattr(agent, '_last_error', None)
and not _captured_terminal_error[0]):
_observe_runtime_model()
# /btw is intentionally non-persistent, but its terminal SSE
# payload is still public output. Project the ephemeral
# session before enqueueing it so raw Agent ``api_content`` or
@@ -12584,6 +12761,8 @@ def _run_agent_streaming(
# the catch-all label, hint, and provider details.
return # apperror already closes the stream on the client side
_observe_runtime_model()
# ── Handle context compression side effects ──
# Also detect compression via the result dict or compressor state
if not _compressed:
@@ -12786,7 +12965,9 @@ def _run_agent_streaming(
# mutates agent.model when a fallback fires, so the pre-run
# resolved_model would mis-attribute exactly the turns where
# attribution matters most.
_used_model = getattr(agent, 'model', None) or resolved_model or model
# The configured selection is not proof that it served this turn.
_observed_model = getattr(agent, 'model', None)
_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None
if _gateway_routing:
s.gateway_routing = _gateway_routing
_history = list(getattr(s, 'gateway_routing_history', None) or [])
@@ -13672,6 +13853,9 @@ def _run_agent_streaming(
if 'credential_pool' in _agent_params:
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
_heal_agent = _AIAgent(**_heal_kwargs)
# Delta callbacks close over `agent`; the replacement, not
# the failed original, owns any successful retry output.
agent = _heal_agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
@@ -13808,6 +13992,7 @@ def _run_agent_streaming(
s, tool_calls=s.tool_calls
)
)
_observe_runtime_model()
if _done_session_payload is not None:
put('done', {
'session': _done_session_payload,