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:
commit
5d42c2acd2
52 files changed
+7013
-284
No files matched your search
@@ -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
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user