diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ce017cf..38530f25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -278,6 +278,26 @@ visibility stage decides whether recovered background rows are shown. In are merged; cross-profile scoping, visibility, deduplication, and final route limits remain downstream responsibilities. +#### Compression lineage and session-list invalidation + +`api.agent_sessions._is_continuation_session()` is the shared classifier for +sidebar projection, lineage metadata/reporting, and `state.db` transcript +stitching. It uses the direct parent link, no conflicting non-empty source, +`compression` or `cli_close` parent end reason, and the existing two-second +`started_at` overlap allowance. A `source="tool"` child is always a separate +conversation, even when its parent is also a tool session or has no source. +A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the +child's `model_config` also makes a boundary; inherited ancestor markers do +not. Malformed or unverifiable marker evidence fails closed as a boundary. +The overlap allowance is the existing master policy, not a new window set by +this change. + +The gateway watcher's cheap database fingerprint includes `model_config`, so +a marker-only update causes a fresh projection. Its published-payload hash +covers every emitted session field, not just ID, activity time and message +count; a changed projected title or lineage field can therefore emit +`sessions_changed` even without message-row churn. + ### 4.3 SSE Streaming Engine This is the most architecturally interesting part. Two endpoints cooperate: @@ -475,6 +495,52 @@ pointer-removed cases to `tests/test_agent_compat.py`. The resolver is compatibility-only: delete it, and import directly from the new homes, once the WebUI stops supporting Agents that predate the split. +### 4.10 MCP Runtime Profile Boundary + +Hermes Agent owns the in-process MCP ledger. It keys a connection by +`(profile_home_key, name)` and registers its tools in that profile's registry overlay +only when the calling task serves a *routed* profile: the context-local Hermes-home +override differs from the process home. Otherwise the connection uses the bare server +name and the global registry slot, which belong to the process profile. + +- `api.profiles._set_hermes_home()` is the single writer of the process-profile home: + startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go + through it, so `get_process_profile_home()` and the Agent pin + (`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated + in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into + `os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a + turn's own profile look like the process profile, so same-named servers of different + profiles share one bare-name connection. +- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run + their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the + request profile's home and secret scope (root profile included) without touching + `os.environ`. Status, tool count and inventory are read from one scope; the inventory + lists only tools in that profile's own registry slot for servers it configures. +- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else + the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared + connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for + status, inventory and the reload summary. The Agent's launch-profile view (scope + `None`) is process-wide, so `filter_runtime_status_to_view()` downgrades + `get_mcp_status()` rows of servers the root profile does not serve to `configured` + rather than showing a routed profile's connection, tool count or connect error. +- The scope is trusted only when the Agent's routing decision matches the profile WebUI + resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an + Agent without the pin), status and inventory withhold runtime data + (`runtime_scope: "unavailable"`, shown as a notice in the MCP panel) and + `/reload-mcp` refuses instead of resetting another owner's connection. +- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's + own scope and the names of its live connections; `scope=None` without `names` is the + process-wide wildcard and is used only with Agents that predate profile-scoped MCP + (`runtime_scope: "legacy_process"`). A scoped shutdown only clears connect backoff for + the live keys it tears down, so WebUI also drops the profile's own cooldown/error + entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed + to spawn is retried by the reload, as the wildcard did, without touching another + owner's backoff. + +Status and inventory stay passive: they never start or probe an MCP server. Ledger key +helpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape +has one owner; the local fallbacks only cover Agents that predate that module. + --- ## 5. Frontend Architecture: Current State @@ -534,7 +600,9 @@ to the active conversation rather than a global app setting. Session management: newSession() POST /api/session/new, update S.session, save to localStorage - loadSession(sid) GET /api/session?session_id=X, check INFLIGHT first, update S + loadSession(sid) GET /api/session?session_id=X (initial load uses the + bounded tail `msg_limit=30`; jump-to-start and outline + jump pass `msg_limit=all`), check INFLIGHT first, update S deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly renderSessionList() GET /api/sessions, rebuild #sessionList DOM @@ -1402,7 +1470,14 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). / Returns full HTML app (index page) /index.html Same as / /health {"status":"ok","sessions":N} - /api/session ?session_id=X -> full session + messages. 400 if no ID. + /api/session ?session_id=X -> session + messages. 400 if no ID. + Bare (no msg_limit) keeps the historical full-transcript + contract. Recovery paths request a bounded tail + (`msg_limit=30`) and restore `_messages_truncated` / + `_messages_offset` before persisting anchor-scene + metadata. Outline jump and jump-to-start opt in to the + full transcript via the explicit `msg_limit=all` + escape hatch. See #7310 / #7625 / #7628. /api/sessions List of all session compact() dicts, sorted by updated_at /api/list ?session_id=X&path=. -> directory listing for session workspace /api/file ?session_id=X&path=rel -> file content (text, 200KB limit) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb2efc3..1586e436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added +- **The settings file can live outside the state directory.** `HERMES_WEBUI_SETTINGS_FILE` points one + instance at its own `settings.json`, while sessions, workspaces and projects stay in the state + directory. It is read once at startup, so restart after changing it. (#6433 by @futureworld678-create) - **Full-session resolve concurrency is configurable.** `HERMES_WEBUI_MAX_SESSION_RESOLVE` sets how many full-transcript session resolves may run at once (default 2, a positive integer up to 64; zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a @@ -17,6 +20,14 @@ ### Performance +- **Reconnect, settle, cancel and undo no longer re-download the whole transcript.** Six recovery + paths (offline/bfcache refresh, stream-end settle, cancel sync, `/compress` preflight, `/retry` and + `/undo`) sent a bare `GET /api/session` that re-walked, re-redacted and re-serialized every row. The + author measured 4–15 s and 28 MB on a 5,003-row session, against 7 ms and 80 KB for the bounded tail. + They now request the 30-row tail. The two views that address rows by absolute index (outline jump, + jump-to-start) opt into the full transcript with the new `?msg_limit=all`, and session-level + `tool_calls` are windowed whenever the returned messages were actually truncated. A bare request + keeps its full-transcript contract. (#7310, #7625, #7628 by @happy5318) - **Opening a session while its task is still running is much faster.** Rebuilding the live snapshot from the run journal parsed the journal twice, walked every metering row and grew the reasoning text with repeated string concatenation, which is quadratic on long runs. The journal @@ -25,9 +36,120 @@ rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is no longer shown twice. (#7310, #7569 by @happy5318) +- **Long transcripts with virtualization on stop re-measuring rows in a loop.** When the rendered + window switched back and forth between two positions, each switch reset the measurement retry + budget, so opt-in transcript virtualization could keep re-measuring rows instead of settling. The + budget now resets only when the window reaches a position it hasn't just visited. (#6654, #6717 by + @webtecnica) ### Fixed +- **Picking a model from a named custom provider sends the right model name.** Choosing a model + that belongs to a non-default custom provider (for example `@custom:my-server:model-x`) sent + the whole picker id, prefix included, to the provider, which rejected the request. The + `@custom::` prefix is now stripped before sending and the provider is routed from the + slug. Endpoint-style slugs such as `custom:localhost:11434` keep their host and port. + (#6895 by @webtecnica, fixes #6884) +- **A gateway reset or tool conversation no longer disappears into the parent session.** When a + session ended by compression, the sidebar also treated a gateway reset child (stamped + `_reset_from`) as its continuation, so that separate conversation vanished from the list and its + transcript was stitched into the parent's. Reset and tool children now stay separate, matching + Hermes Agent's own continuation rule; a real compression continuation still joins the chain. A + lineage marker that can't be read is treated as a boundary, which keeps the row visible rather + than merging it. A change to a session's lineage markers alone now also refreshes the sidebar. + (#6565 by @ruizanthony) +- **A background tab stops polling a session that's gone, and still recovers after a profile + switch in another tab.** A hidden tab polling a deleted session used to loop on 404s every six + seconds. It now stops after three consecutive 404s but keeps the session as its resume target, + so if the 404 came from switching profile in another tab and you switch back, the tab reattaches + when you return to it. A queued poll response can no longer stop a replacement poll for the same + session. (#7301, @laitekin; completes the #7299 fix) + +- **An image turn's provider context stays out of your message bubble, and Edit/Undo no longer + brings a removed image turn back.** When an image turn was mirrored into the Agent's state.db, + its rich provider payload could appear in the user bubble, and after Edit or Undo a removed + image-turn row could come back in the full, paginated and model-context reads. The bubble now + shows what you typed, the payload stays in model context only, and removed rows stay removed, + including a same-timestamp duplicate row and a reply that exists only in state.db after an + edited checkpoint. (#7754, @starship-s) + +- **MCP status, tool inventory and `/reload-mcp` follow the profile you're using.** With several + profiles in one WebUI, a chat turn mirrored its profile into the process environment, so the Agent + saw every profile as the launch profile. The MCP panel could then show another profile's servers, + and `/reload-mcp` could restart them. Status, tool listing and reload now resolve through the + request's profile, and a reload in one profile leaves another profile's live MCP connections and + in-flight tool calls alone. This needs the Agent's `pin_process_hermes_home` (hermes-agent #120103). + On an older Agent the panel says live status is unavailable while a turn runs, and `/reload-mcp` + refuses, rather than guessing. (#7720 by @tancou, fixes #7721) +- **On a phone, Enter in the composer inserts a newline.** Some iPhones report a fine pointer to the + browser, so the phone-keyboard check fell through and plain Enter sent the message mid-sentence. + Phones (iPhone, iPod, and Android phones) now always get a newline on Enter; Ctrl/Cmd+Enter and + the Send button still send, and the Send-key setting still wins. Tablets and touch laptops keep + the existing check, so an iPad with a Magic Keyboard or an Android tablet with a Bluetooth + keyboard still sends on Enter (#3076). The saved Send-key preference is also read before the + first keypress, so the first Enter after a slow page load no longer sends. (#6746 by @happy5318) +- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI + session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar, + because the projection treated a missing sidecar as "not archived". A missing sidecar now means no + opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins. + In all-profiles mode, a CLI archive in another profile now refreshes the cached session list. + (#7548, #7798 by @webtecnica) + +- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.** + A card that surfaced through the normal prompt path never produced a notification, and the + visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify + surfacing notifies unless it belongs to the session open in the pane and the tab is both visible + and focused. Notifications from other sessions still arrive while you work. When permission is + still undecided, the browser is asked at most once per page load, so a pending prompt's 1.5 s + re-surfacing no longer repeats the permission request or the "notifications denied" toast. (#7493 by + @CharlesMcquade) +- **Delegated subagent sessions show inside their parent's project.** With a project selected in the + sidebar, subagent sessions disappeared because `state.db` never gives them a `project_id`. Only + subagent rows now inherit their parent's project, resolved once per lineage, so forks keep their + own "No project" assignment and deep lineages stay fast. (#7765 by @carlotestor) +- **Saving a cron job with the model picker at "Default" keeps its provider-only pin.** Editing a job + pinned to a provider with no model (the usual shape for a self-hosted OpenAI-compatible router) + sent `provider: null` and wiped the pin. The editor now preserves a provider-only pin when the + model picker is left at Default. (#7779 by @cushingw) +- **A consumed mid-turn `/steer` no longer leaves its out-of-band wrapper in the settled chat (#7600).** + A `/steer` reaches the agent as an `[OUT-OF-BAND USER MESSAGE …]` block appended to the turn's + last tool result. After the turn settled, that wrapper stayed visible in the chat transcript. The + settled-transcript writeback now scrubs the consumed wrapper from the rows it is built from. + (#7610 by @webtecnica) +- **Saving a very large session no longer reads and parses the whole file just to count messages.** + The #1558 backup safeguard in `Session.save()` needs the on-disk message count; it obtained it by + loading the entire sidecar. On a real 203 MB / 266,940-message session that made every save + expensive. The count is now taken without a full parse, and the shrink-backup behaviour is + unchanged. (#7578 by @rodrigogs) +- **A cancelled or recovered turn no longer duplicates an answer that was already saved (#6366).** + When a completed assistant turn had been persisted and a later cancel/recovery path ran for the + same pending turn, recovery could append a duplicate user turn plus a `_partial` clone of the + journal. Recovery now stops once the transcript has already advanced past the pending turn. + (#7682 by @happy5318) + +- **The live stream reports which model actually served the turn.** A new additive `runtime_model` + SSE event carries the model (and provider, when known) that the Agent reported while producing + output, separately from the model that was requested. It is journaled for replay, is exposed as + `runtime_journal_snapshot.runtime_model`, never falls back to the configured selection when + unknown, and never changes the model requested for the next turn. Existing clients ignore it. + (#7767 by @ruizanthony) +- **Reconnecting to a new turn no longer resumes from the previous turn's replay cursor.** A new + turn copies the full transcript into the in-flight state, so the previous turn's assistant reply + could seed the replay floor and the reattached stream skipped the current reply's early events. + Replay now seeds only from the current live assistant row, and falls back to a full replay that + rebuilds the assistant body when a cursor outlives its live state. (#7651 by @happy5318) +- **Context-length lookup keeps the configured base URL for ownerless and underscore-named + providers.** The #7535 ownership guard also dropped the global `model.base_url` for a `model:` + section with a `base_url` but no `provider`, and for provider IDs written with an underscore + (`opencode_go` vs `opencode-go`), so those sessions could resolve the wrong context window. Both + now keep the URL, while a different declared owner still does not leak its endpoint. + (#7743 by @webtecnica) +- **A session deleted during a restart no longer produces a spurious recovery warning.** When + WebUI startup recovery re-attached background processes, a session that had vanished between + enumeration and rebind raised a `KeyError` that was logged as a warning. It now follows the + existing skip path, confined to the session lookup, so the vanished owner is skipped, live owners + still rebind, and registry errors still warn. (#7753, #7774 by @happy5318) + - **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's "another Hermes process is using this session" notices now reach the chat as a warning status diff --git a/README.md b/README.md index 76429aea..676d6cd4 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup - Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete - Pin/star sessions to the top of the sidebar (gold indicator) - Archive sessions (hide without deleting, toggle to show) -- Session projects -- named groups with colors for organizing sessions +- Session projects -- named groups with colors for organizing sessions; delegated subagent sessions have no project of their own and follow their nearest ancestor's project in the project filter and the Unassigned chip; forks and other child sessions keep their own project, so a fork moved to "No project" stays Unassigned - Session tags -- add #tag to titles for colored chips and click-to-filter - Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups) - Download as Markdown transcript, full JSON export, or import from JSON @@ -285,12 +285,14 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup ### Settings and configuration - **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password) - Send key: Enter (default) or Ctrl/Cmd+Enter +- Send-key on touch devices: plain Enter inserts a newline on phones (iPhone/iPod, Android phones) and on tablets / iPadOS / touch-capable Macs that only expose a coarse pointer (no attached hardware keyboard), matching the software keyboard's return key. Devices that report a fine pointer (for example, a tablet with a hardware keyboard) keep the configured physical-keyboard send-key behavior. The configured shortcut and the Send button remain available for sending. - Show/hide CLI sessions toggle (enabled by default) - Token usage display toggle (off by default, also via `/usage` command) - Control Center always opens on the Conversation tab; resets on close - Unsaved changes guard -- discard/save prompt when closing with unpersisted changes - Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar - Background agent error alerts -- banner when a non-active session encounters an error +- Approval and clarification browser alerts notify once per pending owner when its session is not actively viewed. Local approval dismissal hides attention without resolving the prompt; resolution, replacement, and cancellation retire that owner's alert state. Denied or failed notification delivery can retry while the prompt remains pending. ### Slash commands - Type `/` in the composer for autocomplete dropdown @@ -361,6 +363,7 @@ Full list of environment variables: | `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) | | `HERMES_WEBUI_PORT` | `8787` | Port | | `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored. **Note (upgrade):** the default now follows `HERMES_HOME` — if you previously relocated `HERMES_HOME` to a non-default base **without** setting `HERMES_WEBUI_STATE_DIR`, your WebUI state now resolves to `$HERMES_HOME/webui` instead of the old platform-default `~/.hermes/webui`. To keep using the old location, set `HERMES_WEBUI_STATE_DIR` to it (or move the directory). Installs with `HERMES_HOME` unset or at the default base are unaffected. | +| `HERMES_WEBUI_SETTINGS_FILE` | `/settings.json` | Optional path for this instance's WebUI settings file (sessions, workspaces and projects stay in the state directory). Read once at startup, so restart after changing it | | `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace | | `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default | | `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication | diff --git a/api/agent_sessions.py b/api/agent_sessions.py index ed3ab538..5b696222 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -366,28 +366,59 @@ def is_cli_session_row_visible(row: dict) -> bool: return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT -def _branch_markers(row: dict | None) -> tuple[str | None, str | None]: - """Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``. +# Every model_config marker Hermes Agent binds to ``parent_session_id`` in its +# non-continuation child predicate (``_NON_CONTINUATION_CHILD_FILTER_SQL``): +# ``_delegate_from`` (delegate_task), ``_branched_from`` (/branch) and +# ``_reset_from`` (gateway reset children, stamped at creation or durably by +# ``reopen_session()`` in ``gateway/session_recovery.py``). +_MODEL_CONFIG_LINEAGE_KEYS = ('_delegate_from', '_branched_from', '_reset_from') - Hermes Agent stamps explicit branches and delegate/subagent runs in the - ``model_config`` JSON column (``_branched_from`` / ``_delegate_from``); - ``session_source='fork'`` alone only covers WebUI-created forks. Missing, - empty, or unparsable ``model_config`` degrades to ``(None, None)``. + +def _branch_markers(row: dict | None) -> tuple[str, dict[str, str]]: + """Return ``(state, markers)`` for a row's ``model_config`` lineage identity. + + Hermes Agent stamps explicit branches, delegate/subagent runs and gateway + reset children in the ``model_config`` JSON column (see + ``_MODEL_CONFIG_LINEAGE_KEYS``); ``session_source='fork'`` alone only + covers WebUI-created forks. + + ``state`` is one of: + + - ``'none'``: no ``model_config``, or an object carrying no lineage marker; + - ``'markers'``: every non-null marker is a non-empty string, returned by key; + - ``'unknown'``: identity evidence exists but cannot be trusted (payload is + not JSON, not a JSON object, too deeply nested to decode, or a marker is + not a non-empty string). Callers must fail closed on this state and treat + the row as a lineage boundary, never as a continuation. """ if not row: - return None, None + return 'none', {} raw = row.get('model_config') - if isinstance(raw, dict): - config = raw - elif isinstance(raw, str) and raw.strip(): + if raw is None: + return 'none', {} + if isinstance(raw, (str, bytes, bytearray)): + if not raw.strip(): + return 'none', {} try: - parsed = json.loads(raw) - except (TypeError, ValueError): - parsed = None - config = parsed if isinstance(parsed, dict) else {} + config = json.loads(raw) + except (TypeError, ValueError, RecursionError): + # RecursionError: a valid but pathologically deep payload exceeds + # the decoder's recursion limit; it is untrusted identity evidence + # like any other undecodable payload, never an escaping crash. + return 'unknown', {} else: - config = {} - return config.get('_branched_from'), config.get('_delegate_from') + config = raw + if not isinstance(config, dict): + return 'unknown', {} + markers: dict[str, str] = {} + for key in _MODEL_CONFIG_LINEAGE_KEYS: + value = config.get(key) + if value is None: + continue + if not isinstance(value, str) or not value.strip(): + return 'unknown', {} + markers[key] = value.strip() + return ('markers', markers) if markers else ('none', {}) def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: @@ -405,30 +436,36 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: conversation; otherwise the tip inherits the root's title/source metadata and can disappear under messaging/sidebar policies. - Explicit branches/delegates never continue: a child whose ``model_config`` - ``_branched_from`` / ``_delegate_from`` points at this parent is a fork of - the conversation and must stay visible. Beyond the bounded tolerance window - there is no reliable continuation signal — titles are user-controlled and - non-unique, so they are never used to widen the window (#7021 re-gate). + Explicit branches/delegates/resets never continue: a child whose + ``model_config`` ``_branched_from`` / ``_delegate_from`` / ``_reset_from`` + points at this parent is a separate conversation and must stay visible. + Unparsable or non-string marker evidence fails closed as a boundary. + Beyond the bounded tolerance window there is no reliable continuation + signal — titles are user-controlled and non-unique, so they are never used + to widen the window (#7021 re-gate). """ if not parent or not child: return False if str(child.get('session_source') or '').strip().lower() == 'fork': return False - # Real Agent branches/delegates are marked in model_config (not + # Real Agent branches/delegates/resets are marked in model_config (not # session_source, which only WebUI-created forks carry). The marker must # reference THIS parent: compression continuations inherit the rotated # agent's model_config verbatim, so presence alone (a delegate's # continuation still carries the delegate's own ``_delegate_from``) would - # misclassify real continuations. - child_branched_from, child_delegate_from = _branch_markers(child) - parent_id = parent.get('id') - if parent_id and ( - child_branched_from == parent_id or child_delegate_from == parent_id - ): + # misclassify real continuations. Untrusted identity evidence fails closed. + marker_state, child_markers = _branch_markers(child) + if marker_state == 'unknown': + return False + parent_id = str(parent.get('id') or '').strip() + if parent_id and parent_id in child_markers.values(): return False parent_source = str(parent.get('source') or '').strip().lower() child_source = str(child.get('source') or '').strip().lower() + # Agent lineage excludes tool children even when the parent has the same + # source (or no source); a timestamp overlap cannot override that boundary. + if child_source == 'tool': + return False if parent_source and child_source and parent_source != child_source: return False if parent.get('end_reason') not in {'compression', 'cli_close'}: @@ -576,7 +613,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: # expects from "Show agent sessions" sorted by activity. for key in ( 'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count', - 'ended_at', 'end_reason', 'last_activity', + 'ended_at', 'end_reason', 'last_activity', 'archived', ): if key in tip: merged[key] = tip[key] @@ -683,6 +720,7 @@ def read_importable_agent_session_rows( origin_chat_id_expr = _optional_col('origin_chat_id', session_cols) origin_user_id_expr = _optional_col('origin_user_id', session_cols) platform_expr = _optional_col('platform', session_cols) + archived_expr = _optional_col('archived', session_cols) # Older/minimal state.db schemas can have NO ``messages`` table at all, # or a ``messages`` table without a ``session_id`` / ``timestamp`` column. # The projection SQL below joins ``messages`` and aggregates @@ -793,6 +831,7 @@ def read_importable_agent_session_rows( {parent_expr}, {ended_expr}, {end_reason_expr}, + {archived_expr}, {actual_count_expr} AS actual_message_count, {user_message_count_expr} AS actual_user_message_count, {last_activity_expr} AS last_activity diff --git a/api/background_process.py b/api/background_process.py index 6e6323af..eea15ebb 100644 --- a/api/background_process.py +++ b/api/background_process.py @@ -1883,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( diff --git a/api/commands.py b/api/commands.py index 31f72315..2bb85eae 100644 --- a/api/commands.py +++ b/api/commands.py @@ -257,11 +257,28 @@ def _run_codex_runtime_command(arg_string: str) -> str: def _run_reload_mcp_command() -> str: - """Execute the MCP reconnect path and return a short user-facing summary.""" + """Execute the MCP reconnect path and return a short user-facing summary. + + Scoped to the request profile: only that profile's connections are shut + down and rediscovered from that profile's config, so other profiles' + same-named servers keep running (agents with profile-scoped MCP; older + agents keep their process-wide reload). + """ with _RELOAD_MCP_LOCK: try: + import importlib from tools.mcp_tool import _servers, _lock + # Resolved by full name (like the ``from`` import above) so a stubbed + # ``tools.mcp_tool`` without a ``tools`` package still works. + _core = importlib.import_module("tools.mcp_tool") from api.agent_compat import agent_attr + from api.mcp_runtime import ( + accepts_keywords, + clear_profile_connect_cooldowns, + ledger_key_serves_view, + mcp_key_name, + mcp_runtime_scope, + ) shutdown_mcp_servers = agent_attr( "tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle" @@ -269,22 +286,74 @@ def _run_reload_mcp_command() -> str: discover_mcp_tools = agent_attr( "tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery" ) + try: + register_mcp_servers = agent_attr( + "tools.mcp_tool", "register_mcp_servers", "tools.mcp_tool_discovery" + ) + except Exception: + register_mcp_servers = None # older agent: no overlay reconciliation except Exception as exc: logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True) raise RuntimeError("MCP runtime unavailable") from exc - try: - with _lock: - old_servers = set(_servers.keys()) + with mcp_runtime_scope("/reload-mcp") as view: + if not view.trusted: + # Resetting under an unconfirmed scope could stop another profile's + # connection or rebuild this one under the wrong owner. + raise RuntimeError( + "MCP reload unavailable: this profile's MCP runtime scope could not be " + "confirmed (a chat turn on this profile may be running); retry when it finishes" + ) + try: + # Profile-scoped teardown needs both the scoped ledger and a + # shutdown that takes scope/names; otherwise keep the legacy reload. + legacy = view.legacy or not accepts_keywords(shutdown_mcp_servers, "scope", "names") - shutdown_mcp_servers() - new_tools = discover_mcp_tools() + def live_names() -> set: + # Same criteria as the status page: the connection serves this + # profile (owned or adopted) and is live. A parked task (failed + # spawn retained for retry) has no session and is not "connected". + return { + mcp_key_name(key) for key, server in _servers.items() + if (legacy or ledger_key_serves_view(_core, key, view)) + and getattr(server, "session", True) is not None + } - with _lock: - connected_servers = set(_servers.keys()) - except Exception as exc: - logger.warning("Failed to reload MCP servers", exc_info=True) - raise RuntimeError("Failed to reload MCP servers") from exc + with _lock: + old_servers = live_names() + # Every ledger entry serving this profile, parked ones included: + # a parked task left in place would make discovery skip the server. + owned_names = { + mcp_key_name(key) for key in _servers + if ledger_key_serves_view(_core, key, view) + } + + if legacy: + shutdown_mcp_servers() + else: + # ``names`` is required: ``scope=None`` alone is the process-wide + # wildcard. Only names with a ledger key are passed; shutdown + # ignores anything else, and a key of another owner is never selected. + shutdown_mcp_servers(scope=view.registry_scope, names=owned_names) + # The wildcard also dropped connect backoff; keep that for this + # profile's own failed servers so the reload retries them now. + clear_profile_connect_cooldowns(_core, view) + # A connection this profile ADOPTED (another profile's identical + # route) is not torn down above, and ``discover_mcp_tools()`` + # returns before its reconcile step when the profile's config + # has no server left, so the adopted tools would stay callable. + # ``register_mcp_servers({})`` judges every server serving this + # scope against the profile's config on disk and detaches only + # this profile's overlay; the owner's connection keeps running. + if register_mcp_servers is not None and view.registry_scope is not None: + register_mcp_servers({}) + new_tools = discover_mcp_tools() + + with _lock: + connected_servers = live_names() + except Exception as exc: + logger.warning("Failed to reload MCP servers", exc_info=True) + raise RuntimeError("Failed to reload MCP servers") from exc added = connected_servers - old_servers removed = old_servers - connected_servers diff --git a/api/config.py b/api/config.py index 27e10cf9..7158e6c9 100644 --- a/api/config.py +++ b/api/config.py @@ -98,10 +98,19 @@ STATE_DIR = ( .resolve() ) + +def _resolve_settings_file(state_dir: Path) -> Path: + """Resolve an optional per-instance settings file without splitting session state.""" + configured = os.getenv("HERMES_WEBUI_SETTINGS_FILE", "").strip() + if configured: + return Path(configured).expanduser().resolve() + return state_dir / "settings.json" + + SESSION_DIR = STATE_DIR / "sessions" WORKSPACES_FILE = STATE_DIR / "workspaces.json" SESSION_INDEX_FILE = SESSION_DIR / "_index.json" -SETTINGS_FILE = STATE_DIR / "settings.json" +SETTINGS_FILE = _resolve_settings_file(STATE_DIR) LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt" PROJECTS_FILE = STATE_DIR / "projects.json" diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 1ce74e9a..1daa497f 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -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__) @@ -1515,7 +1516,11 @@ def _run_gateway_chat_streaming( # role/content ordering instead of turn order. assistant_ts = now + 0.000001 pending_source = getattr(s, "pending_user_source", None) or "webui" - from api.streaming import _active_turn_authority, _materialize_active_turn_user + from api.streaming import ( + _active_turn_authority, + _active_turn_token_matches, + _materialize_active_turn_user, + ) active_turn_identity = _active_turn_authority(s, stream_id, msg_text) user_msg = _materialize_active_turn_user( @@ -1576,6 +1581,9 @@ def _run_gateway_chat_streaming( s.context_messages, str(msg_text or ""), source=pending_source, + verification_nudge_provenance={ + "active_turn_identity": active_turn_identity, + }, ) except Exception: logger.debug("Failed to merge gateway display transcript", exc_info=True) @@ -1588,6 +1596,14 @@ def _run_gateway_chat_streaming( if latest_text == msg_norm: display = display[:-1] s.messages = display + [user_msg, assistant_msg] + if active_turn_identity.get("token"): + current_display_rows = [ + message + for message in s.messages + if _active_turn_token_matches(message, active_turn_identity) + ] + if len(current_display_rows) == 1: + current_display_rows[0]["timestamp"] = user_msg["timestamp"] s.active_stream_id = None s.gateway_run = None s.pending_user_message = None @@ -1623,6 +1639,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 diff --git a/api/gateway_watcher.py b/api/gateway_watcher.py index 69b1bbb6..c5f86180 100644 --- a/api/gateway_watcher.py +++ b/api/gateway_watcher.py @@ -9,6 +9,7 @@ This enables real-time session list updates in the sidebar without requiring any changes to hermes-agent. """ import hashlib +import json import logging import os import queue @@ -27,12 +28,27 @@ logger = logging.getLogger(__name__) # ── State hash tracking ───────────────────────────────────────────────────── def _snapshot_hash(sessions: list) -> str: - """Create a lightweight hash of session IDs and timestamps for change detection.""" - key = '|'.join( - f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}" - for s in sorted(sessions, key=lambda x: x['session_id']) - ) - return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest() + """Hash the complete published session payload for change detection. + + Every emitted field participates, not only the id / activity timestamp / + message count triple. Projection authority (compression collapse, + ``model_config`` lineage markers, title promotion) can change a row's + ``title``, ``created_at`` or source metadata while that triple stays fixed; + hashing only the triple let the projection rerun, keep ``_last_sessions`` + stale and emit no ``sessions_changed`` event. Entries are canonicalised + (sorted keys, stable separators) and ordered by ``session_id`` so the hash + is deterministic and independent of sidebar ordering; cost is bounded by + the projection's own row limit. + """ + digest = hashlib.md5(usedforsecurity=False) + for session in sorted(sessions, key=lambda x: str(x.get('session_id') or '')): + digest.update( + json.dumps( + session, sort_keys=True, separators=(',', ':'), default=str + ).encode('utf-8', 'replace') + ) + digest.update(b'\x1e') + return digest.hexdigest() # Sources excluded from the WebUI sidebar projection. Must match the default @@ -70,10 +86,10 @@ def _cheap_change_fingerprint(db_path: Path) -> str | None: # are always present (``source`` is required for the projection to run at # all); the rest are optional on older agent schemas and filtered below. _PROJECTION_SESSION_COLS = ( - 'id', 'source', 'session_source', 'title', 'model', 'message_count', - 'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived', - 'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key', - 'origin_chat_id', 'origin_user_id', 'platform', + 'id', 'source', 'session_source', 'model_config', 'title', 'model', + 'message_count', 'started_at', 'ended_at', 'end_reason', + 'parent_session_id', 'archived', 'user_id', 'chat_id', 'chat_type', + 'thread_id', 'session_key', 'origin_chat_id', 'origin_user_id', 'platform', ) try: with closing(open_state_db_readonly(db_path)) as conn: diff --git a/api/helpers.py b/api/helpers.py index 1984bf28..f0d2cba2 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -24,6 +24,8 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({ "_active_turn_token", "_active_turn_user", "_fork_child_turn", + "_webui_trusted_agent_input_text", + "_webui_unmatched_native_image_mirror", }) diff --git a/api/mcp_runtime.py b/api/mcp_runtime.py new file mode 100644 index 00000000..c4b2658d --- /dev/null +++ b/api/mcp_runtime.py @@ -0,0 +1,325 @@ +"""Profile-bound view of Hermes Agent's in-process MCP runtime. + +The MCP status page, the tool inventory and ``/reload-mcp`` read or reset +Hermes Agent's MCP ledger from the HTTP request thread. Hermes Agent scopes +that ledger per profile (connection key ``(profile_home_key, name)``) only +when the calling task serves a *routed* profile, i.e. its context-local +Hermes-home override differs from the process home. These helpers bind the +request profile's home (without touching ``os.environ``) and check that the +agent's routing decision matches the profile WebUI resolved, so a read never +shows another profile's connection and a reload never resets one. + +Ledger helpers below take ``core`` (``tools.mcp_tool``) explicitly and read +its maps with defaults, so an agent that lacks a map degrades to "unscoped" +rather than raising inside a status read. +""" + +from __future__ import annotations + +import importlib +import inspect +import logging +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Generator, Iterable, Optional + +logger = logging.getLogger(__name__) + +# Scope label reported by the MCP endpoints. +SCOPE_PROFILE = "profile" # runtime view bound to the request profile +SCOPE_LEGACY = "legacy_process" # agent predates profile-scoped MCP; process-wide view +SCOPE_UNAVAILABLE = "unavailable" # profile scope could not be confirmed; runtime hidden + + +@dataclass(frozen=True) +class McpRuntimeView: + """How the current context sees Hermes Agent's MCP runtime. + + ``trusted``: runtime reads/writes in this context belong to ``profile_home``. + ``legacy``: the agent has no profile-scoped MCP ledger (process-wide by name). + ``registry_scope``: the tool-registry slot owning this profile's MCP tools + (the profile overlay key when routed, ``None`` for the process profile). + """ + + profile_home: Path + trusted: bool + legacy: bool + registry_scope: Optional[str] = None + + @property + def scope_label(self) -> str: + if not self.trusted: + return SCOPE_UNAVAILABLE + return SCOPE_LEGACY if self.legacy else SCOPE_PROFILE + + +def _routing_view(profile_home: Path, override_bound: bool) -> McpRuntimeView: + try: + from agent.secret_scope import serves_routed_profile + from hermes_constants import hermes_home_key + from tools.registry import registry + except ImportError: + return McpRuntimeView(profile_home, trusted=True, legacy=True) + from api.profiles import get_process_profile_home + + try: + expected_routed = hermes_home_key(profile_home) != hermes_home_key(get_process_profile_home()) + # Without the override a routed profile cannot be served; with it, the agent must + # agree. It disagrees when a streaming turn has mirrored this profile's home into + # os.environ['HERMES_HOME'] on an agent without a pinned process home. + if expected_routed and not override_bound: + return McpRuntimeView(profile_home, trusted=False, legacy=False) + if bool(serves_routed_profile()) != expected_routed: + return McpRuntimeView(profile_home, trusted=False, legacy=False) + scope = registry.current_scope_key() if expected_routed else None + except Exception: + logger.debug("Failed to resolve MCP runtime scope for %s", profile_home, exc_info=True) + return McpRuntimeView(profile_home, trusted=False, legacy=False) + return McpRuntimeView(profile_home, trusted=True, legacy=False, registry_scope=scope) + + +@contextmanager +def mcp_runtime_scope(purpose: str) -> Generator[McpRuntimeView, None, None]: + """Bind the active request profile for Hermes Agent MCP runtime calls. + + Installs the context-local Hermes-home override and secret scope for the + request profile (including the root profile) and restores them on exit, + including on exceptions. Never mutates ``os.environ``, starts, or probes + MCP servers. + """ + from api.profiles import get_active_hermes_home, profile_env_for_active_request_readonly + + profile_home = Path(get_active_hermes_home()) + with profile_env_for_active_request_readonly( + purpose, logger_override=logger, include_root=True + ) as override_bound: + yield _routing_view(profile_home, bool(override_bound)) + + +def accepts_keywords(fn: Callable, *names: str) -> bool: + """True when *fn*'s signature exposes every keyword in *names*. + + Introspection failures (builtins, C callables, exotic wrappers) count as + "not supported" so callers fall back to the bare call instead of dropping + the whole read. + """ + try: + params = inspect.signature(fn).parameters + except (TypeError, ValueError): + return False + return all(name in params for name in names) + + +# ── Ledger keys ────────────────────────────────────────────────────────────── +# Hermes Agent's own helpers (``tools.mcp_tool_scope``) are preferred so the key +# shape has one owner; the lambdas only cover agents that predate that module. + +def _key_helper(name: str, fallback: Callable) -> Callable: + try: + # Full-name import: works with a stubbed module and no ``tools`` package. + mcp_tool_scope = importlib.import_module("tools.mcp_tool_scope") + except Exception: + return fallback + helper = getattr(mcp_tool_scope, name, None) + return helper if callable(helper) else fallback + + +def mcp_key_name(key: Any) -> str: + """Server name of an MCP connection-ledger key (bare name or ``(scope, name)``).""" + return str(_key_helper("_key_name", lambda k: k[1] if isinstance(k, tuple) else k)(key)) + + +def mcp_key_scope(key: Any) -> Optional[str]: + """Owning registry scope encoded in a ledger key (``None`` for a bare key).""" + return _key_helper("_key_scope", lambda k: k[0] if isinstance(k, tuple) else None)(key) + + +def ledger_key_owner(core: Any, key: Any) -> Optional[str]: + """Scope owning the connection under *key*: recorded at adoption, else the key's own.""" + owners = getattr(core, "_server_scope_keys", None) + if isinstance(owners, dict) and key in owners: + return owners[key] + return mcp_key_scope(key) + + +def ledger_key_serves_view(core: Any, key: Any, view: McpRuntimeView) -> bool: + """True when the connection under *key* serves the profile in *view*. + + Owned by the view's registry scope, or adopted into it (a routed profile + sharing an identical connection; ``_server_tool_scopes``). Hermes Agent's + own launch-profile view (scope ``None``) is process-wide, so this predicate + is what narrows the root profile to its unscoped connections. Legacy agents + have one process-wide ledger, so every key counts. + """ + if view.legacy: + return True + if ledger_key_owner(core, key) == view.registry_scope: + return True + if view.registry_scope is None: + return False + adopters = getattr(core, "_server_tool_scopes", None) + if not isinstance(adopters, dict): + return False + return view.registry_scope in (adopters.get(key) or ()) + + +@dataclass(frozen=True) +class ServedRuntime: + """Runtime state of one server name as seen from a profile's ledger keys.""" + + live_tools: Optional[int] = None # tool count of a live (session-bearing) connection + connecting: bool = False + error: Optional[str] = None + lazy_tools: Optional[int] = None + + @property + def live(self) -> bool: + return self.live_tools is not None + + +def _served_keys(core: Any, ledger: str, view: McpRuntimeView) -> list: + keys = getattr(core, ledger, None) + if not isinstance(keys, (dict, set, frozenset, list, tuple)): + return [] + return [key for key in list(keys) if ledger_key_serves_view(core, key, view)] + + +def view_served_runtime(core: Any, view: McpRuntimeView) -> dict[str, ServedRuntime]: + """Per-name runtime state of the ledger entries serving *view*. + + Caller holds ``core._lock`` or tolerates a racy read (status surfaces). + """ + live: dict[str, int] = {} + connecting: set[str] = set() + errors: dict[str, str] = {} + lazy: dict[str, int] = {} + servers = getattr(core, "_servers", None) + if isinstance(servers, dict): + for key in _served_keys(core, "_servers", view): + server = servers.get(key) + if server is None or getattr(server, "session", None) is None: + continue # parked / mid-connect task: retained, not connected + names = getattr(server, "_registered_tool_names", None) + if not isinstance(names, (list, tuple, set)): + names = getattr(server, "_tools", None) or () + live[mcp_key_name(key)] = len(names) + for key in _served_keys(core, "_server_connecting", view): + connecting.add(mcp_key_name(key)) + error_map = getattr(core, "_server_connect_errors", None) + if isinstance(error_map, dict): + for key in _served_keys(core, "_server_connect_errors", view): + errors[mcp_key_name(key)] = str(error_map.get(key, "")) + lazy_tools = getattr(core, "_lazy_server_tool_names", None) + for key in _served_keys(core, "_lazy_server_configs", view): + count = lazy_tools.get(key) if isinstance(lazy_tools, dict) else None + lazy[mcp_key_name(key)] = len(count) if isinstance(count, (list, tuple, set)) else 0 + out: dict[str, ServedRuntime] = {} + for name in set(live) | connecting | set(errors) | set(lazy): + out[name] = ServedRuntime( + live_tools=live.get(name), + connecting=name in connecting, + error=errors.get(name), + lazy_tools=lazy.get(name), + ) + return out + + +_RUNTIME_ROW_FIELDS = ("connected", "tools", "status", "error", "sampling", "tool_schemas") + + +def filter_runtime_status_to_view(statuses: Iterable[Any], view: McpRuntimeView) -> list: + """Rebuild the runtime fields of ``get_mcp_status()`` rows from the keys serving *view*. + + Under a routed scope the agent already filters by owner/adopter; under the + launch profile (scope ``None``) it reports every profile's connection and + indexes them by bare name, so the root profile would show "Active, N tools" + for a routed profile's server it never connected (or its own parked server + as connected because another profile's same-named one is live). Rows keep + their config-derived fields (name, transport, disabled) and get + ``connected`` / ``tools`` / ``status`` / ``error`` from this profile's own + ledger entries only. + """ + rows = [row for row in statuses if isinstance(row, dict)] + if view.legacy or not rows: + return rows + try: + core = importlib.import_module("tools.mcp_tool") + except Exception: + return rows + lock = getattr(core, "_lock", None) + try: + if lock is not None: + with lock: + served = view_served_runtime(core, view) + else: + served = view_served_runtime(core, view) + except Exception: + logger.debug("Failed to read MCP ledger ownership for %s", view.profile_home, exc_info=True) + return [] + out = [] + for row in rows: + rebuilt = {k: v for k, v in row.items() if k not in _RUNTIME_ROW_FIELDS} + state = served.get(str(row.get("name"))) + disabled = bool(row.get("disabled")) or row.get("status") == "disabled" + rebuilt["connected"] = bool(state and state.live) + rebuilt["tools"] = state.live_tools if state and state.live else ( + state.lazy_tools if state and state.lazy_tools is not None and not disabled else 0 + ) + if state and state.live: + rebuilt["status"] = "connected" + elif disabled: + rebuilt["status"] = "disabled" + elif state and state.connecting: + rebuilt["status"] = "connecting" + elif state and state.error is not None: + rebuilt["status"] = "failed" + rebuilt["error"] = state.error + elif state and state.lazy_tools is not None: + rebuilt["status"] = "lazy" + else: + rebuilt["status"] = "configured" + out.append(rebuilt) + return out + + +_COOLDOWN_LEDGERS = ("_server_connect_retry_after", "_server_connect_failures", "_server_connect_errors") + + +def clear_profile_connect_cooldowns(core: Any, view: McpRuntimeView) -> None: + """Forget connect backoff/error state of the servers *view*'s profile owns. + + A server that failed to spawn never reaches ``_servers``, so a scoped + ``shutdown_mcp_servers(scope=..., names=...)`` (which clears cooldowns for + the live keys it tears down) leaves its backoff in place and the following + ``discover_mcp_tools()`` skips it. The process-wide wildcard cleared every + cooldown; this keeps ``/reload-mcp`` a retry for the profile's own servers + without touching another owner's backoff. Owned keys only, never adopted ones. + """ + lock = getattr(core, "_lock", None) + if lock is None: + return + with lock: + for ledger in _COOLDOWN_LEDGERS: + entries = getattr(core, ledger, None) + if not isinstance(entries, dict): + continue + for key in [k for k in entries if ledger_key_owner(core, k) == view.registry_scope]: + entries.pop(key, None) + + +def registry_tool_owned_by_view(registry, tool_name: str, view: McpRuntimeView) -> bool: + """True when *tool_name* is registered in this profile's own registry slot. + + The merged registry view overlays a profile's tools on the global slot, so a + routed profile would otherwise also list the process profile's MCP tools. + """ + if view.legacy: + return True + snapshot = getattr(registry, "snapshot_registration", None) + if not callable(snapshot): + return True + try: + return snapshot(tool_name, scope=view.registry_scope) is not None + except Exception: + return False diff --git a/api/models.py b/api/models.py index 3f3c2853..88bfbba1 100644 --- a/api/models.py +++ b/api/models.py @@ -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. @@ -1268,6 +1276,36 @@ def _strip_sidebar_heavy_metadata(row: dict) -> dict: return row +def _validated_webui_pending_user_timestamp_identity(session, value): + if not isinstance(value, (tuple, list)) or len(value) != 2: + return None + stream_id = getattr(session, 'active_stream_id', None) + source = str(getattr(session, 'pending_user_source', '') or '').strip().lower() + if ( + not isinstance(stream_id, str) + or not stream_id + or value[0] != stream_id + or not isinstance(getattr(session, 'pending_user_message', None), str) + or not getattr(session, 'pending_user_message', None) + or source not in {'webui', 'fork'} + ): + return None + pending_timestamp, pending_valid = _message_exact_timestamp_details( + {'timestamp': getattr(session, 'pending_started_at', None)} + ) + identity_timestamp, identity_valid = _message_exact_timestamp_details( + {'timestamp': value[1]} + ) + if ( + not pending_valid + or not identity_valid + or pending_timestamp is None + or pending_timestamp != identity_timestamp + ): + return None + return (stream_id, pending_timestamp) + + class Session: def __init__(self, session_id: str=None, title: str='Untitled', workspace=str(DEFAULT_WORKSPACE), created_workspace=None, @@ -1371,6 +1409,11 @@ class Session: self.pending_attachments = pending_attachments or [] self.pending_started_at = pending_started_at self.pending_user_source = pending_user_source + self._webui_pending_user_timestamp_identity = ( + _validated_webui_pending_user_timestamp_identity( + self, kwargs.get('_webui_pending_user_timestamp_identity') + ) + ) self.context_messages = context_messages if isinstance(context_messages, list) else [] self.compression_anchor_visible_idx = compression_anchor_visible_idx self.compression_anchor_message_key = compression_anchor_message_key @@ -1469,6 +1512,11 @@ class Session: ) if touch_updated_at: self.updated_at = time.time() + self._webui_pending_user_timestamp_identity = ( + _validated_webui_pending_user_timestamp_identity( + self, getattr(self, '_webui_pending_user_timestamp_identity', None) + ) + ) # Write metadata fields first so load_metadata_only() can read them # without parsing the full messages array (which may be 400KB+). # Fields are listed in the order they should appear in the JSON file. @@ -1479,6 +1527,7 @@ class Session: 'cache_read_tokens', 'cache_write_tokens', 'personality', 'active_stream_id', 'pending_user_message', 'pending_attachments', 'pending_started_at', 'pending_user_source', + '_webui_pending_user_timestamp_identity', 'compression_anchor_visible_idx', 'compression_anchor_message_key', 'compression_anchor_summary', 'pre_compression_snapshot', 'context_engine', 'compression_anchor_engine', 'compression_anchor_mode', @@ -1508,6 +1557,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) @@ -1520,7 +1579,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('_')} @@ -1540,12 +1599,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 @@ -1563,6 +1656,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 @@ -2445,6 +2542,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 ``":"`` — 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): @@ -2646,6 +2928,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, @@ -3522,6 +3852,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 = { @@ -3995,21 +4348,83 @@ def _sync_sidecar_from_state_db_if_newer(session) -> bool: ) if not state_messages: return False + + # The Agent row is intentionally hidden from display while the saved + # handoff proof is active. Materialize the exact WebUI-owned prompt + # before reconciling that row away, so clearing pending state cannot + # erase the only durable copy of the submitted text/attachments. + pending_identity = _validated_webui_pending_user_timestamp_identity( + locked, + getattr(locked, '_webui_pending_user_timestamp_identity', None), + ) + # When a legacy sidecar has no explicit context_messages, the shared + # context reconciler falls back to messages. Capture that model-facing + # merge before adding the display-only WebUI owner row below. + merged_context = ( + reconciled_state_db_messages_for_session( + locked, + prefer_context=True, + state_messages=state_messages, + ) + if pending_identity is not None + else None + ) + if pending_identity is not None: + pending_timestamp = pending_identity[1] + pending_text = locked.pending_user_message + pending_source = getattr(locked, 'pending_user_source', None) or 'webui' + pending_attachments = list(getattr(locked, 'pending_attachments', None) or []) + pending_row = { + 'role': 'user', + 'content': pending_text, + 'timestamp': pending_timestamp, + } + stamp_message_source(pending_row, pending_source) + if str(pending_source or '').strip().lower() == 'fork': + pending_row['_fork_child_turn'] = locked.session_id + if pending_attachments: + pending_row['attachments'] = pending_attachments + + existing_pending = next(( + message for message in locked_messages + if isinstance(message, dict) + and ( + _message_exact_timestamp_details(message) + == (pending_timestamp, True) + and (message.get('_source') or 'webui') == pending_source + and _message_matches_pending_text(message, pending_text) + ) + ), None) + if existing_pending is not None: + # A prior eager checkpoint already owns this exact proved turn; + # refresh it from the still-authoritative pending fields. + existing_pending.update(pending_row) + if pending_attachments: + existing_pending['attachments'] = pending_attachments + else: + existing_pending.pop('attachments', None) + else: + # The proof makes this submitted row authoritative even if a + # clock adjustment places it before the surviving sidecar tail. + if not _insert_state_message_chronologically(locked_messages, pending_row): + locked_messages.insert(0, pending_row) + locked.messages = locked_messages + + display_baseline_count = len(locked_messages) merged_messages = reconciled_state_db_messages_for_session( locked, state_messages=state_messages, ) - # The reconciler is append-only: a genuine state.db advance (output the - # lost stream never wrote back) shows up as MORE rows than the sidecar. - # A merged length not greater than the sidecar means nothing new to - # recover — leave the sidecar untouched rather than rewriting in place. - if len(merged_messages) <= locked_count: + # The baseline includes any proven WebUI-owned pending display row. Only + # a later state.db row is an advance worth committing and clearing pending. + if len(merged_messages) <= display_baseline_count: return False - merged_context = reconciled_state_db_messages_for_session( - locked, - prefer_context=True, - state_messages=state_messages, - ) + if merged_context is None: + merged_context = reconciled_state_db_messages_for_session( + locked, + prefer_context=True, + state_messages=state_messages, + ) # Mutate + persist the freshly-loaded, locked object. Because we hold the # lock and reloaded under it, this save cannot clobber a concurrent @@ -4242,6 +4657,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). @@ -7713,7 +8179,7 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: stops being true (metadata moves to another store), this gate would short- circuit before the real source — update both together. """ - default = {"title": None, "archived": False} + default = {"title": None, "archived": None} if not is_safe_session_id(sid): return dict(default) p = SESSION_DIR / f'{sid}.json' @@ -7910,7 +8376,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or f'{_source.title()} Session' cli_sessions.append({ 'session_id': sid, @@ -7982,7 +8451,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or 'Cron Session' cli_sessions.append({ 'session_id': sid, @@ -8048,7 +8520,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or 'Webhook Session' cli_sessions.append({ 'session_id': sid, @@ -8113,7 +8588,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) cli_sessions.append({ 'session_id': sid, 'title': _title or 'Kanban Session', @@ -8467,8 +8945,8 @@ def _project_state_db_message(row, available, id_col, optional): Shared by ``get_state_db_session_messages`` and the regeneration single-snapshot helper so the bounded tail can never drift from the canonical reader: JSON-decode content/tool_calls/reasoning payloads, omit - empty fields, keep durable row id private (``_state_db_row_id`` only for - real Agent api_content replays), and apply ``tool_name → name``. + empty fields, keep durable row id private for provider replays and native + image projections, and apply ``tool_name → name``. """ msg = { 'role': row['role'], @@ -8484,11 +8962,21 @@ def _project_state_db_message(row, available, id_col, optional): if col in {'tool_calls', 'reasoning_details', 'codex_reasoning_items', 'codex_message_items'}: value = _json_loads_if_string(value) msg[col] = value + native_image_projection = ( + msg.get('role') == 'user' + and isinstance(msg.get('content'), str) + and '[screenshot]' in msg['content'] + ) if ( id_col and row['id'] is not None - and isinstance(msg.get('api_content'), str) - and msg['api_content'] + and ( + native_image_projection + or ( + isinstance(msg.get('api_content'), str) + and msg['api_content'] + ) + ) ): msg['_state_db_row_id'] = row['id'] if msg.get('role') == 'tool' and msg.get('tool_name') and not msg.get('name'): @@ -9157,6 +9645,8 @@ def _session_message_key_with_sidecar(base_key: tuple, msg: dict) -> tuple: _SESSION_MESSAGE_IMAGE_PART_TYPES = {"image", "image_url", "input_image"} +_WEBUI_TRUSTED_AGENT_INPUT_FIELD = "_webui_trusted_agent_input_text" +_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD = "_webui_unmatched_native_image_mirror" def _agent_durable_multimodal_content(msg: dict) -> str | None: @@ -9237,6 +9727,202 @@ def _session_message_multimodal_mirror_key( ) +def _native_image_leading_text(message): + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + return None + parts = [] + for part in content: + if not isinstance(part, dict): + return None + part_type = str(part.get("type") or "").lower() + if part_type in _SESSION_MESSAGE_IMAGE_PART_TYPES: + return "\n".join(parts) + if part_type not in {"", "text", "input_text", "output_text"}: + return None + text = part.get("text", part.get("input_text", part.get("output_text", ""))) + if not isinstance(text, str): + return None + parts.append(text) + return None + + +def _suppress_native_image_display_mirrors( + session, + state_messages, + *, + suppress_api_content=True, + suppress_pending_turn=True, +): + """Drop proven pending Agent rows and native-image mirrors from display.""" + if not state_messages: + return state_messages + + if suppress_pending_turn: + identity = _validated_webui_pending_user_timestamp_identity( + session, + getattr(session, "_webui_pending_user_timestamp_identity", None), + ) + if identity is not None: + # A matching timestamp is an Agent handoff identity only because + # the live worker recorded that this exact value was passed via + # persist_user_timestamp. If rows collide on it, hide the whole + # ambiguous bucket from display; model context retains every row. + pending_timestamp = identity[1] + state_messages = [ + message for message in state_messages + if not ( + isinstance(message, dict) + and str(message.get("role") or "").lower() == "user" + and _message_exact_timestamp(message) == pending_timestamp + ) + ] + + display_messages = getattr(session, "messages", None) or [] + context_messages = getattr(session, "context_messages", None) or [] + if not display_messages or not context_messages or not state_messages: + return state_messages + + display_by_token = collections.defaultdict(list) + context_by_token = collections.defaultdict(list) + for message in display_messages: + if isinstance(message, dict) and message.get("_active_turn_token"): + display_by_token[message["_active_turn_token"]].append(message) + for message in context_messages: + if isinstance(message, dict) and message.get("_active_turn_token"): + context_by_token[message["_active_turn_token"]].append(message) + + display_row_id_counts = collections.Counter() + context_row_id_counts = collections.Counter() + for messages, counts in ( + (display_messages, display_row_id_counts), + (context_messages, context_row_id_counts), + ): + for message in messages: + row_id, valid = _state_db_row_identity_details(message) + if valid and row_id is not None: + counts[row_id] += 1 + + from api.streaming import _submitted_user_text_matches + + mirrors = collections.defaultdict(list) + for token, contexts in context_by_token.items(): + displays = display_by_token.get(token, []) + for context in contexts: + trusted_input = context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD) + leading_text = _native_image_leading_text(context) + if ( + not isinstance(trusted_input, str) + or leading_text is None + or not _submitted_user_text_matches(leading_text, trusted_input) + ): + continue + key = _session_message_multimodal_mirror_key( + context, + require_image_parts=True, + ) + if key is None: + continue + display = displays[0] if len(contexts) == len(displays) == 1 else None + display_link_valid = False + if display is not None and display.get("role") == "user": + context_ts, context_ts_valid = _message_exact_timestamp_details(context) + display_ts, display_ts_valid = _message_exact_timestamp_details(display) + display_link_valid = ( + context_ts_valid + and display_ts_valid + and context_ts is not None + and display_ts == context_ts + and _message_private_identity_compatible(display, context) + ) + # Keep trusted rich candidates even when display linkage is + # ambiguous, so a matching scalar row is preserved unless its + # durable row id proves it is the exact Agent projection. + mirrors[key].append((context, display, display_link_valid)) + if not mirrors: + return state_messages + + row_id_counts = collections.Counter() + stable_id_counts = collections.Counter() + state_mirror_keys = [] + for message in state_messages: + if not isinstance(message, dict): + state_mirror_keys.append(None) + continue + row_id, row_id_valid = _state_db_row_identity_details(message) + stable_id, stable_id_valid = _stable_message_identity_details(message) + if row_id_valid and row_id is not None: + row_id_counts[row_id] += 1 + if stable_id_valid and stable_id is not None: + stable_id_counts[stable_id] += 1 + state_mirror_keys.append( + _session_message_multimodal_mirror_key( + message, + require_scalar_mirror=True, + ) + ) + + suppress = set() + marked = {} + for index, key in enumerate(state_mirror_keys): + if key is None or key not in mirrors: + continue + message = state_messages[index] + row_id, row_id_valid = _state_db_row_identity_details(message) + stable_id, stable_id_valid = _stable_message_identity_details(message) + contexts = mirrors[key] + matched = None + preserve_distinct_row = False + if len(contexts) == 1: + context, display, display_link_valid = contexts[0] + context_row_id, context_row_id_valid = _state_db_row_identity_details(context) + display_row_id, display_row_id_valid = _state_db_row_identity_details(display) + linked_context_row = ( + display is not None + and display_link_valid + and context_row_id_valid + and display_row_id_valid + and context_row_id is not None + and context_row_id == display_row_id + and context_row_id_counts[context_row_id] == 1 + and display_row_id_counts[display_row_id] == 1 + ) + if ( + linked_context_row + and (not row_id_valid or row_id is None or row_id != context_row_id) + and (row_id is None or row_id_counts[row_id] == 1) + and stable_id_valid + and (stable_id is None or stable_id_counts[stable_id] == 1) + ): + preserve_distinct_row = True + if ( + linked_context_row + and row_id_valid + and row_id is not None + and context_row_id == row_id + and row_id_counts[row_id] == 1 + and stable_id_valid + and (stable_id is None or stable_id_counts[stable_id] == 1) + and _message_private_identity_compatible(context, message) + ): + matched = context + if ( + matched is not None + and (suppress_api_content or not _session_message_api_content_key(message)) + ): + suppress.add(index) + elif preserve_distinct_row: + marked[index] = { + **message, + _WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD: True, + } + return [ + marked.get(index, message) + for index, message in enumerate(state_messages) + if index not in suppress + ] + + # Per-call memo of structured-content identities. Reconciliation derives merge, # dedup, content and visible keys for every message, several per source, so a # large multimodal payload would otherwise be serialised once per key. The memo @@ -9540,7 +10226,7 @@ def _visible_content_compatible(target: dict | None, source: dict | None) -> boo def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool: - """Copy a non-empty internal sidecar without replacing an existing one.""" + """Copy a valid sidecar unless the target already has a valid one.""" if not isinstance(target, dict) or not isinstance(source, dict): return False target_role = _message_sidecar_role(target) @@ -9559,7 +10245,8 @@ def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool: ) ): return False - if target.get("api_content") not in (None, ""): + target_api_content = target.get("api_content") + if isinstance(target_api_content, str) and target_api_content: return True api_content = source.get("api_content") if isinstance(api_content, str) and api_content: @@ -10504,6 +11191,89 @@ def merge_session_messages_append_only( _STRUCTURED_IDENTITY_MEMO.reset(token) +def _project_native_image_payload_conflicts_for_display( + sidecar_messages, + state_messages, + merged_messages, +): + """Project a proven same-row native-image conflict onto its sidecar bubble.""" + sidecar_messages = list(sidecar_messages or []) + state_messages = list(state_messages or []) + if not sidecar_messages or not state_messages: + return merged_messages + + sidecar_row_id_counts = collections.Counter( + row_id + for message in sidecar_messages + if (row_id := _state_db_row_identity(message)) is not None + ) + state_row_id_counts = collections.Counter( + row_id + for message in state_messages + if (row_id := _state_db_row_identity(message)) is not None + ) + display_conflicts = {} + for incoming in state_messages: + if ( + not isinstance(incoming, dict) + or incoming.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is not True + ): + continue + row_id, row_id_valid = _state_db_row_identity_details(incoming) + timestamp, timestamp_valid = _message_exact_timestamp_details(incoming) + content = incoming.get("content") + if ( + not row_id_valid + or row_id is None + or sidecar_row_id_counts[row_id] != 1 + or state_row_id_counts[row_id] != 1 + or not timestamp_valid + or timestamp is None + or str(incoming.get("role") or "").lower() != "user" + or not isinstance(content, str) + ): + continue + incoming_api_content = _session_message_api_content_key(incoming) + if incoming_api_content is None: + continue + sidecar_owner = next(( + message for message in sidecar_messages + if isinstance(message, dict) + and _state_db_row_identity_details(message) == (row_id, True) + and _message_identity_compatible(message, incoming) + and message.get("content") == content + and _message_exact_timestamp_details(message) == (timestamp, True) + and _session_message_api_content_key(message) + not in (None, incoming_api_content) + ), None) + if sidecar_owner is not None: + display_conflicts[(row_id, timestamp, content)] = (incoming, sidecar_owner) + + if not display_conflicts: + return merged_messages + visible_messages = [] + for message in merged_messages: + if isinstance(message, dict) and isinstance(message.get("content"), str): + row_id, row_id_valid = _state_db_row_identity_details(message) + timestamp, timestamp_valid = _message_exact_timestamp_details(message) + conflict = ( + display_conflicts.get((row_id, timestamp, message["content"])) + if row_id_valid and row_id is not None and timestamp_valid + else None + ) + if conflict is not None and _message_identity_compatible(message, conflict[0]): + incoming, sidecar_owner = conflict + is_sidecar_owner = ( + _message_identity_compatible(message, sidecar_owner) + and _session_message_api_content_key(message) + == _session_message_api_content_key(sidecar_owner) + ) + if not is_sidecar_owner: + continue + visible_messages.append(message) + return visible_messages + + def _merge_session_messages_append_only_impl( sidecar_messages: list, state_messages: list, @@ -10826,14 +11596,217 @@ def _merge_session_messages_append_only_impl( and boundary_ts is not None and boundary_ts < watermark_timestamp ) - for msg in state_messages: + + def _state_row_is_truncated( + msg, key, content_key, timestamp, checkpoint_consumed, + *, retained_native_image_row: bool | None = None, + ): + # Skip rows ABOVE the watermark only while the sidecar has NOT advanced + # past the watermark. Because Session.save() no longer auto-clears the + # watermark, an unconditional `timestamp > watermark` skip would become + # permanent and silently drop legitimate future state.db-only recovery + # rows once the session moves forward past the edit boundary. Once the + # sidecar's own max timestamp is beyond the watermark, allow state rows + # newer than the sidecar tail to merge. + # + # The sidecar's max timestamp can also EQUAL the watermark when the new + # post-edit USER turn has been checkpointed into the sidecar (its + # timestamp == the advanced watermark) but its ASSISTANT reply exists + # only in state.db (recovery before the sidecar tail advances). In that + # state truncation_boundary < watermark proves the session is genuinely + # advanced, so the post-watermark state-only reply is legitimate + # post-edit content and must merge through (not be dropped as a replaced + # tail). The conservative skip still applies for boundary is None and + # boundary == watermark (not-advanced / legacy). + # + # CRITICAL: the boundary-advanced signal may only bypass the skip AFTER + # state replay has consumed the sidecar's visible checkpoint + # (state_replay_idx >= len(sidecar_visible_sequence)). A deleted suffix + # row with ts > watermark that appears in state.db BEFORE the edited + # checkpoint must still be skipped — otherwise the advanced signal would + # resurrect it. The sidecar-max-timestamp signal needs no such gate (a + # sidecar tail beyond the watermark is itself proof the checkpoint has + # advanced). + sidecar_advanced_past_watermark = ( + watermark_timestamp is not None + and ( + (max_sidecar_timestamp is not None + and max_sidecar_timestamp > watermark_timestamp) + or (watermark_advanced_by_boundary and checkpoint_consumed) + ) + ) + message_key_seen = key in seen_message_keys + content_key_seen = content_key in seen_content_keys + if retained_native_image_row is not None: + # A marked image mirror needs durable row proof; an equal scalar + # projection from another row cannot exempt it from the watermark. + message_key_seen = content_key_seen = retained_native_image_row + if ( + watermark_timestamp is not None + and timestamp is not None + and timestamp > watermark_timestamp + and not message_key_seen + and ( + not sidecar_advanced_past_watermark + or (max_sidecar_timestamp is not None and timestamp <= max_sidecar_timestamp) + ) + ): + return True + # When a truncation watermark is active, state.db may contain original + # messages that were replaced by Edit (old content with old timestamp). + # The timestamp-based filter above catches messages AFTER the watermark, + # but messages BEFORE it (like the original pre-edit content) slip through. + # If a state.db message's content is not present in the sidecar and its + # timestamp is before the watermark, it's a replaced/stale row — skip it. + if ( + watermark_timestamp is not None + and timestamp is not None + and timestamp < watermark_timestamp + and not message_key_seen + and not content_key_seen + ): + return True + # Same-second edit: if timestamp equals the watermark and the message + # content is not in the sidecar, it's a replaced message edited at the + # same second — skip it. The edited version (same timestamp, different + # content) is in the sidecar and survives this check. + # + # Only apply the same-second guard to user messages. An assistant reply + # (or tool message) at the same second as the watermark is a legitimate + # post-edit recovery row — the sidecar holds only the edited user + # checkpoint, so the assistant reply's content won't be in it and would + # be silently dropped without this role guard. + return ( + watermark_timestamp is not None + and timestamp is not None + and timestamp == watermark_timestamp + and not message_key_seen + and not content_key_seen + and str(msg.get("role", "")).lower() == "user" + ) + + for source_message in state_messages: + preserve_native_image_row = ( + isinstance(source_message, dict) + and source_message.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is True + ) + msg = ( + { + key: value + for key, value in source_message.items() + if key != _WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD + } + if preserve_native_image_row + else source_message + ) timestamp = _message_timestamp_as_float(msg) key = _cached_message_key(msg, "merge") dedup_key = _cached_message_key(msg, "dedup") visible_key = _cached_message_key(msg, "visible_state") content_key = _cached_message_key(msg, "content_state") + if preserve_native_image_row: + row_id, row_id_valid = _state_db_row_identity_details(msg) + existing = ( + merged_by_row_id.get(row_id) + if row_id_valid and row_id is not None + else None + ) + row_id_fast_path_allowed = ( + existing is not None + and row_id not in ambiguous_row_ids + and _row_id_fast_path_allowed(existing, msg) + ) + existing_timestamp, existing_timestamp_valid = ( + _message_exact_timestamp_details(existing) + ) + incoming_timestamp, incoming_timestamp_valid = ( + _message_exact_timestamp_details(msg) + ) + retained_native_image_row = ( + row_id_fast_path_allowed + and sidecar_row_id_counts.get(row_id, 0) == 1 + and state_row_id_counts.get(row_id, 0) == 1 + and existing_timestamp_valid + and incoming_timestamp_valid + and existing_timestamp is not None + and existing_timestamp == incoming_timestamp + and str(msg.get("role") or "").lower() == "user" + ) + if _state_row_is_truncated( + msg, + key, + content_key, + timestamp, + state_replay_idx >= len(sidecar_visible_sequence), + retained_native_image_row=retained_native_image_row, + ): + continue + if row_id_fast_path_allowed: + # This state row replays the sidecar row it resolved to, so it + # consumes that position in the replay sequence exactly like the + # ordinary and multimodal-mirror paths do. Without this the + # checkpoint never reads as consumed and a later state-only + # reply after an edited checkpoint is truncated. + if ( + state_replay_idx < len(sidecar_visible_messages) + and sidecar_visible_messages[state_replay_idx] is existing + ): + state_replay_idx += 1 + existing_api_content = _session_message_api_content_key(existing) + incoming_api_content = _session_message_api_content_key(msg) + if ( + existing_api_content is not None + and incoming_api_content is not None + and existing_api_content != incoming_api_content + ): + # Row identity does not establish which provider payload is + # newer. A unique exact row match proves this row survived + # truncation, so preserve both versions for model context. + pass + else: + if existing_api_content is None and incoming_api_content is not None: + _copy_api_content_sidecar(existing, msg) + _merge_session_display_metadata(existing, msg) + continue + if dedup_key in seen_dedup_keys: + duplicate = merged_by_dedup_key.get(dedup_key) + duplicate_row_id, duplicate_row_id_valid = ( + _state_db_row_identity_details(duplicate) + ) + two_distinct_durable_rows = ( + row_id_valid + and row_id is not None + and duplicate_row_id_valid + and duplicate_row_id is not None + and duplicate_row_id != row_id + ) + if not two_distinct_durable_rows: + _merge_session_display_metadata(duplicate, msg) + continue + duplicate = merged_by_row_id.get(row_id) + duplicate_row_id, duplicate_row_id_valid = ( + _state_db_row_identity_details(duplicate) + ) + same_durable_row = ( + row_id not in ambiguous_row_ids + and duplicate_row_id_valid + and duplicate_row_id == row_id + and _cached_message_key(duplicate, "dedup") == dedup_key + and _row_id_fast_path_allowed(duplicate, msg) + ) + if same_durable_row: + _merge_session_display_metadata(duplicate, msg) + continue + if not _insert_state_message_chronologically(merged_messages, msg): + merged_messages.append(msg) + seen_message_keys.add(key) + seen_dedup_keys.add(dedup_key) + seen_content_keys.add(content_key) + seen_visible_keys.add(visible_key) + _remember_merged_message(msg, source="state") + continue multimodal_mirror_key = ( - state_multimodal_mirror_keys.get(id(msg)) + state_multimodal_mirror_keys.get(id(source_message)) if sidecar_multimodal_mirrors else None ) @@ -10911,83 +11884,9 @@ def _merge_session_messages_append_only_impl( _copy_api_content_sidecar(existing, msg) _merge_session_display_metadata(existing, msg) continue - # Skip rows ABOVE the watermark only while the sidecar has NOT advanced - # past the watermark. Because Session.save() no longer auto-clears the - # watermark, an unconditional `timestamp > watermark` skip would become - # permanent and silently drop legitimate future state.db-only recovery - # rows once the session moves forward past the edit boundary. Once the - # sidecar's own max timestamp is beyond the watermark (the session has - # advanced), allow state rows newer than the sidecar tail to merge. - # - # The sidecar's max timestamp can also EQUAL the watermark when the new - # post-edit USER turn has been checkpointed into the sidecar (its - # timestamp == the advanced watermark) but its ASSISTANT reply exists - # only in state.db (recovery before the sidecar tail advances). In that - # state truncation_boundary < watermark proves the session is genuinely - # advanced, so the post-watermark state-only reply is legitimate - # post-edit content and must merge through (not be dropped as a replaced - # tail). The conservative skip still applies for boundary is None and - # boundary == watermark (not-advanced / legacy). - # - # CRITICAL: the boundary-advanced signal may only bypass the skip AFTER - # state replay has consumed the sidecar's visible checkpoint - # (state_replay_idx >= len(sidecar_visible_sequence)). A deleted suffix - # row with ts > watermark that appears in state.db BEFORE the edited - # checkpoint must still be skipped — otherwise the advanced signal would - # resurrect it. The sidecar-max-timestamp signal needs no such gate (a - # sidecar tail beyond the watermark is itself proof the checkpoint has - # advanced). checkpoint_consumed = state_replay_idx >= len(sidecar_visible_sequence) - sidecar_advanced_past_watermark = ( - watermark_timestamp is not None - and ( - (max_sidecar_timestamp is not None - and max_sidecar_timestamp > watermark_timestamp) - or (watermark_advanced_by_boundary and checkpoint_consumed) - ) - ) - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp > watermark_timestamp - and key not in seen_message_keys - and ( - not sidecar_advanced_past_watermark - or (max_sidecar_timestamp is not None and timestamp <= max_sidecar_timestamp) - ) - ): - continue - # When a truncation watermark is active, state.db may contain original - # messages that were replaced by Edit (old content with old timestamp). - # The timestamp-based filter above catches messages AFTER the watermark, - # but messages BEFORE it (like the original pre-edit content) slip through. - # If a state.db message's content is not present in the sidecar and its - # timestamp is before the watermark, it's a replaced/stale row — skip it. - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp < watermark_timestamp - and key not in seen_message_keys - and content_key not in seen_content_keys - ): - continue - # Same-second edit: if timestamp equals the watermark and the message - # content is not in the sidecar, it's a replaced message edited at the - # same second — skip it. The edited version (same timestamp, different - # content) is in the sidecar and survives this check. - # - # Only apply the same-second guard to user messages. An assistant reply - # (or tool message) at the same second as the watermark is a legitimate - # post-edit recovery row — the sidecar holds only the edited user - # checkpoint, so the assistant reply's content won't be in it and would - # be silently dropped without this role guard. - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp == watermark_timestamp - and key not in seen_message_keys - and content_key not in seen_content_keys - and str(msg.get("role", "")).lower() == "user" + if _state_row_is_truncated( + msg, key, content_key, timestamp, checkpoint_consumed, ): continue # Check for true duplicates using full-precision timestamp (#3346). @@ -11190,6 +12089,12 @@ def reconciled_state_db_messages_for_session( state_messages = state_result.messages else: state_messages = state_result + state_messages = _suppress_native_image_display_mirrors( + session, + state_messages, + suppress_api_content=not using_context_messages, + suppress_pending_turn=not prefer_context, + ) if prefer_context and local_messages: if using_context_messages: sidecar_messages = getattr(session, 'messages', None) or [] @@ -11241,6 +12146,12 @@ def reconciled_state_db_messages_for_session( truncation_boundary=getattr(session, "truncation_boundary", None), incoming_provenance="state_db", ) + if not prefer_context: + reconciled_messages = _project_native_image_payload_conflicts_for_display( + local_messages, + state_messages, + reconciled_messages, + ) return _state_db_session_messages_result( reconciled_messages, state_revision, diff --git a/api/profiles.py b/api/profiles.py index e56b32fd..c0a6fdb1 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -56,6 +56,11 @@ _loaded_profile_env_keys: set[str] = set() # process-global _active_profile. _tls = threading.local() +# Home of the profile this process serves as its own (set by init_profile_state). +# Streaming turns mirror their profile into os.environ['HERMES_HOME'], so the live +# env var is not a stable identity; see _pin_process_profile_home(). +_PROCESS_PROFILE_HOME: Optional[str] = None + _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool") _SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock() @@ -1360,6 +1365,8 @@ def profile_env_for_background_worker( def profile_env_for_active_request_readonly( purpose: str = "provider/model read", logger_override: Optional[logging.Logger] = None, + *, + include_root: bool = False, ): """Apply the active per-request profile's env to thread-local state only (#3957). @@ -1376,11 +1383,18 @@ def profile_env_for_active_request_readonly( process-global ``os.environ``. No-ops for the default/root profile, which is the common single-profile - deployment case. + deployment case, unless ``include_root`` is set: callers whose Hermes Agent + reads must not follow a streaming turn's mirrored ``HERMES_HOME`` (MCP + runtime status/reload) bind the root profile's home explicitly too. + + Yields True when the context-local Hermes-home override is installed for + the resolved profile, else False (no-op, older agent, or resolution error). """ profile = (get_active_profile_name() or "").strip() - if not profile or _is_root_profile(profile): - yield + if include_root and not profile: + profile = "default" + if not profile or (_is_root_profile(profile) and not include_root): + yield False return try: from api.config import _clear_thread_env, _set_thread_env, _thread_ctx @@ -1396,7 +1410,7 @@ def profile_env_for_active_request_readonly( purpose, exc_info=True, ) - yield + yield False return try: from hermes_constants import ( @@ -1440,7 +1454,7 @@ def profile_env_for_active_request_readonly( purpose, exc_info=True, ) - yield + yield home_override_installed finally: if _has_scope and _secret_scope_mod is not None: try: @@ -1529,8 +1543,14 @@ def profile_scope_for_detached_worker( def _set_hermes_home(home: Path): - """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" + """Set HERMES_HOME env var and monkey-patch cached module-level paths. + + Every process-wide home change (startup, ``switch_profile(process_wide=True)``) + goes through here, so the process-profile pin used for MCP routing decisions + is updated in the same step and cannot drift from ``HERMES_HOME``. + """ os.environ['HERMES_HOME'] = str(home) + _pin_process_profile_home(home) patch_skill_home_modules(home) @@ -1610,11 +1630,45 @@ def init_profile_state() -> None: else: _active_profile = _read_active_profile_file() home = get_active_hermes_home() - _set_hermes_home(home) + _set_hermes_home(home) # also pins the process-profile home (MCP routing anchor) install_cron_scheduler_profile_isolation() _reload_dotenv(home) +def _pin_process_profile_home(home: Path) -> None: + """Record the profile home this process serves as its own, for WebUI and Hermes Agent. + + Called from ``_set_hermes_home()`` so startup and process-wide profile switches + keep one owner for the value. Hermes Agent keys MCP connections and registry + overlays by profile only when a task serves a *routed* profile: the context-local + Hermes-home override differs from the process home. Streaming turns mirror their + profile into ``os.environ['HERMES_HOME']`` for legacy readers, which makes every + turn's own profile look like the process profile, so same-named MCP servers of + different profiles share one bare-name connection. Agents exposing + ``hermes_constants.pin_process_hermes_home`` take a stable anchor instead; older + agents keep following the env var, and ``get_process_profile_home()`` still gives + WebUI the anchor to detect that skew. + """ + global _PROCESS_PROFILE_HOME + _PROCESS_PROFILE_HOME = str(home) + try: + import hermes_constants + pin = getattr(hermes_constants, 'pin_process_hermes_home', None) + if callable(pin): + pin(str(home)) + except Exception: + logger.debug("Hermes Agent process-home pin unavailable", exc_info=True) + + +def get_process_profile_home() -> Path: + """Return the profile home this WebUI process serves as its own (stable per process).""" + if _PROCESS_PROFILE_HOME: + return Path(_PROCESS_PROFILE_HOME) + if _INITIAL_HERMES_HOME: + return Path(_INITIAL_HERMES_HOME).expanduser() + return _DEFAULT_HERMES_HOME + + def switch_profile(name: str, *, process_wide: bool = True) -> dict: """Switch the active profile. diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index b91ba7b8..e382a287 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -606,6 +606,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple session_index_path = _session_list_cache_session_dir() / "_index.json" except Exception: session_index_path = None + all_profiles_state_stamp = None + if _cache_all_profiles: + try: + from api.models import _all_profiles_cli_contexts + contexts, _ = _all_profiles_cli_contexts() + all_profiles_state_stamp = tuple( + ( + _session_list_cache_path_stamp(ctx_db), + _session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None), + _session_list_cache_state_db_fingerprint(ctx_db), + ) + for _home, ctx_db, _prof in contexts + ) + except Exception: + all_profiles_state_stamp = None + return ( _session_list_cache_path_stamp(state_db_path), _session_list_cache_path_stamp(state_db_wal_path), @@ -617,6 +633,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple # frame size), so without this a freshly-committed CLI/gateway session # could be served stale for the cache TTL. Mirrors the models-layer fix. _session_list_cache_state_db_fingerprint(state_db_path), + all_profiles_state_stamp, swv, ) diff --git a/api/routes.py b/api/routes.py index ead29cc8..a81660f8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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): @@ -9103,9 +9132,11 @@ def _parse_msg_limit(raw): """Parse and clamp the ``?msg_limit=`` query value. Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when - the value is absent/empty/malformed (the bare no-``msg_limit`` path, which - intentionally returns the full transcript for callers that need it). - Extracted from the handler so the clamp expression has direct test coverage. + the value is absent/empty/malformed. ``?msg_limit=all`` also returns + ``None`` — an explicit escape hatch for the full-transcript paths the + frontend genuinely needs; the handler distinguishes it from a bare request + via :func:`_resolve_effective_msg_limit`. Extracted from the handler so + the clamp expression has direct test coverage. """ if not raw: return None @@ -9116,6 +9147,26 @@ def _parse_msg_limit(raw): return max(1, min(value, _MAX_MSG_LIMIT)) +def _resolve_effective_msg_limit(raw_limit): + """Resolve the effective ``msg_limit`` for ``GET /api/session``. + + Returns ``(effective_limit, explicit_all)``. + + - numeric ``?msg_limit=N`` → clamped int (existing pagination). + - ``?msg_limit=all`` → ``(None, True)``: explicit full-transcript escape + hatch. Frontend paths that address rows by absolute transcript index + (outline jump, jump-to-start) genuinely need everything; they pass + ``all`` instead of relying on the bare no-limit shape. + - any other bare shape (no limit) → ``(None, False)``: the historical + full-transcript contract is preserved (contract tests pin tool-row + preservation and the runtime-journal snapshot on this shape), so the + bounded-window fix is enforced at the frontend call sites instead. + """ + explicit_all = str(raw_limit or "").strip().lower() == "all" + limit = _parse_msg_limit(raw_limit) + return limit, explicit_all + + # If a sidecar JSON file exceeds this threshold, the display-path tail # optimization fires regardless of message count. Sessions with few messages # but large tool outputs (multi-MB JSON) should not force a full-scan merge. @@ -9298,6 +9349,13 @@ def _limited_webui_messages_for_display_with_sidecar( state_db_messages = list(state_db_messages or []) if not state_db_messages: return sidecar_messages + state_db_messages = _suppress_native_image_display_mirrors( + session, + state_db_messages, + ) + if not state_db_messages: + return sidecar_messages + # NOTE: do not short-circuit to the sidecar when state.db has no strictly # newer rows. A state.db row whose timestamp is at-or-before the sidecar's # newest (recovery / edited-in-place / missing-timestamp cases) is still @@ -9358,6 +9416,11 @@ def _limited_webui_messages_for_display_with_sidecar( truncation_boundary=getattr(session, "truncation_boundary", None), incoming_provenance="state_db", ) + merged = _project_native_image_payload_conflicts_for_display( + sidecar_messages, + state_db_messages, + merged, + ) if cache_key is not None: _state_key = cache_key[4] _streaming_key = ( @@ -10053,7 +10116,35 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list: -def _merged_webui_lineage_messages_for_display(session, messages=None) -> list: +_LINEAGE_PARENT_SESSION_UNSET = object() + + +def _webui_lineage_parent_session_for_display(session): + """Load the immediate parent only for display-eligible continuations.""" + parent_id = str(getattr(session, "parent_session_id", "") or "").strip() + if not parent_id: + return None + if ( + str(getattr(session, "compression_recovery_source_session_id", "") or "").strip() + and str(getattr(session, "compression_recovery_action", "") or "").strip() + ): + return None + source = str(getattr(session, "session_source", "") or "").strip().lower() + relationship = str(getattr(session, "relationship_type", "") or "").strip().lower() + if source == "fork" or relationship == "child_session": + return None + try: + return get_session(parent_id, metadata_only=False) + except Exception: + return None + + +def _merged_webui_lineage_messages_for_display( + session, + messages=None, + *, + parent_session=_LINEAGE_PARENT_SESSION_UNSET, +) -> list: """Include immediate parent-only rows when a WebUI continuation sidecar is partial. Compression/continuation sessions should render as one conversation. Most @@ -10064,23 +10155,9 @@ def _merged_webui_lineage_messages_for_display(session, messages=None) -> list: subset of their parent. """ primary_messages = list(messages if messages is not None else (getattr(session, "messages", []) or [])) - parent_id = str(getattr(session, "parent_session_id", "") or "").strip() - if not parent_id: - return primary_messages - if ( - str(getattr(session, "compression_recovery_source_session_id", "") or "").strip() - and str(getattr(session, "compression_recovery_action", "") or "").strip() - ): - return primary_messages - source = str(getattr(session, "session_source", "") or "").strip().lower() - relationship = str(getattr(session, "relationship_type", "") or "").strip().lower() - if source == "fork" or relationship == "child_session": - return primary_messages - try: - parent = get_session(parent_id, metadata_only=False) - except Exception: - return primary_messages - parent_messages = list(getattr(parent, "messages", []) or []) + if parent_session is _LINEAGE_PARENT_SESSION_UNSET: + parent_session = _webui_lineage_parent_session_for_display(session) + parent_messages = list(getattr(parent_session, "messages", []) or []) if not parent_messages: return primary_messages if _messages_start_with_visible_prefix(primary_messages, parent_messages): @@ -10560,7 +10637,6 @@ from api.models import ( new_session, all_sessions, title_from, - _write_session_index, SESSION_INDEX_FILE, _active_state_db_path, load_projects, @@ -10574,6 +10650,8 @@ from api.models import ( get_state_db_session_message_keys_before_timestamp, get_state_db_session_summary, merge_session_messages_append_only, + _project_native_image_payload_conflicts_for_display, + _suppress_native_image_display_mirrors, _reconcile_api_content_sidecars, _enrich_sidebar_lineage_metadata, _active_stream_ids, @@ -10839,6 +10917,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, @@ -13151,8 +13230,10 @@ def _handle_session_get(handler, parsed) -> bool: # payload; the existing _messages_truncated signal covers the clamped # case (the client sees there are more rows than returned). Parsing + # clamping live in _parse_msg_limit so the expression has direct test - # coverage; None means the bare no-msg_limit path (full transcript). - msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0]) + # coverage. The frontend recovery paths request a bounded tail + # explicitly (msg_limit=30), and the two absolute-index paths opt in to + # the full transcript via msg_limit=all (#7310/#7625). + _raw_msg_limit = query.get("msg_limit", [None])[0] # ?msg_before=N — 0-based index into the full message array. # Returns messages before this index (for scroll-to-top lazy loading). # Combined with msg_limit for paging. @@ -13161,6 +13242,9 @@ def _handle_session_get(handler, parsed) -> bool: msg_before = int(_msg_before) if _msg_before else None except (ValueError, TypeError): msg_before = None + msg_limit, _msg_limit_explicit_all = _resolve_effective_msg_limit( + _raw_msg_limit, + ) # ?expand_renderable=1 is retained for compatibility with older # frontends. msg_limit now counts visible transcript rows by default, so # the flag no longer changes the server-side pagination semantics. @@ -13314,13 +13398,33 @@ def _handle_session_get(handler, parsed) -> bool: msg_before=msg_before, ) else: + state_db_messages = _suppress_native_image_display_mirrors( + s, + state_db_messages, + ) + sidecar_messages = _webui_sidecar_lineage_messages_for_display(s) + lineage_parent = _webui_lineage_parent_session_for_display(s) + projection_sidecar_messages = _merged_webui_lineage_messages_for_display( + s, + sidecar_messages, + parent_session=lineage_parent, + ) _all_msgs = merge_session_messages_append_only( - _webui_sidecar_lineage_messages_for_display(s), + sidecar_messages, state_db_messages, truncation_watermark=getattr(s, "truncation_watermark", None), truncation_boundary=getattr(s, "truncation_boundary", None), ) - _all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs) + _all_msgs = _merged_webui_lineage_messages_for_display( + s, + _all_msgs, + parent_session=lineage_parent, + ) + _all_msgs = _project_native_image_payload_conflicts_for_display( + projection_sidecar_messages, + state_db_messages, + _all_msgs, + ) else: if is_messaging_session and cli_messages: _all_msgs = _merged_session_messages_for_display(s, cli_messages) @@ -13370,9 +13474,13 @@ def _handle_session_get(handler, parsed) -> bool: _messages_offset = 0 # Index of the first returned message in the full message array. # Frontend uses this as cursor for scroll-to-top paging. + # Session-level tool_calls windowing keys off whether the returned + # message array was actually truncated (msg_before paging, any + # effective msg_limit) rather than whether a limit parameter was + # present — the full-transcript shape returns everything, so the + # length comparison alone decides (#7310/#7625). _windowed_messages = ( load_messages - and msg_limit is not None and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs)) ) # Resolve effective context_length with model-metadata fallback so @@ -13468,7 +13576,7 @@ def _handle_session_get(handler, parsed) -> bool: "tool_calls": _session_tool_calls, "active_stream_id": getattr(s, "active_stream_id", None), "pending_user_message": getattr(s, "pending_user_message", None), - "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [], + "pending_attachments": getattr(s, "pending_attachments", []) if (load_messages or getattr(s, "pending_user_message", None)) else [], "pending_started_at": getattr(s, "pending_started_at", None), "pending_user_source": getattr(s, "pending_user_source", None), "context_length": _persisted_cl, @@ -23106,6 +23214,7 @@ def _prepare_chat_start_session_for_stream( s.pending_attachments = attachments s.pending_started_at = started_at if started_at is not None else time.time() s.pending_user_source = effective_source + s._webui_pending_user_timestamp_identity = None if retained_user is not None: from api.process_event_utils import build_active_turn_token @@ -25157,10 +25266,12 @@ def _handle_chat_sync(handler, body): _active_turn_boundary, _assign_stable_message_ids, _dedupe_replayed_context_messages, + _find_active_turn_checkpoint_index, _merge_display_messages_after_agent_result, _resolve_active_turn_authority, _restore_display_reasoning_metadata, _restore_reasoning_metadata_before_boundary, + _settle_current_turn_boundary, _sanitize_messages_for_agent, _compact_session_image_parts_for_persistence, _context_messages_for_new_turn, @@ -25225,6 +25336,33 @@ def _handle_chat_sync(handler, body): result=result, agent=agent, ) + if ( + isinstance(_active_turn_identity, dict) + and _active_turn_identity.get("agent_turn_boundary_resolved") is True + and not _active_turn_identity.get("token") + ): + _active_image_index = _find_active_turn_checkpoint_index( + _result_messages, + _previous_context_messages, + _active_turn_identity, + msg, + ) + _active_image_content = ( + _result_messages[_active_image_index].get("content") + if _active_image_index is not None + else None + ) + if isinstance(_active_image_content, list) and any( + isinstance(part, dict) + and part.get("type") in {"image", "image_url", "input_image"} + for part in _active_image_content + ): + from api.process_event_utils import build_active_turn_token + + _active_turn_identity["token"] = build_active_turn_token( + f"sync:{s.session_id}:{_active_turn_identity['turn_id']}", + time.time(), + ) _turn_boundary = _active_turn_boundary( _result_messages, _previous_context_messages, _active_turn_identity, msg, ) @@ -25243,6 +25381,14 @@ def _handle_chat_sync(handler, body): _next_context_messages, msg, ) + if _active_turn_identity.get("token"): + _next_context_messages = _settle_current_turn_boundary( + _previous_context_messages, + _next_context_messages, + _active_turn_identity, + msg, + getattr(s, "pending_user_source", None) or "webui", + ) s.context_messages = _next_context_messages s.messages = _merge_display_messages_after_agent_result( _previous_messages, @@ -25252,6 +25398,9 @@ def _handle_chat_sync(handler, body): ), msg, source=getattr(s, "pending_user_source", None) or "webui", + verification_nudge_provenance={ + "active_turn_identity": _active_turn_identity, + }, ) _compact_session_image_parts_for_persistence(s) # Only auto-generate title when still default; preserves user renames @@ -29285,17 +29434,34 @@ def _parse_mcp_enabled(value) -> bool: return True -def _mcp_runtime_status_by_name() -> dict[str, dict]: +def _mcp_runtime_status_by_name(servers=None, view=None) -> dict[str, dict]: """Return already-known MCP runtime status without starting servers. ``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent is unavailable, fall back to an empty map so the API remains safe. + + Call it inside ``mcp_runtime_scope()`` and pass its ``view``: the agent + filters a routed profile's connections itself, but its launch-profile view + is process-wide, so rows are narrowed to the connections serving ``view`` + (``api.mcp_runtime.filter_runtime_status_to_view``). ``servers`` (the + profile's ``mcp_servers`` WebUI displays) is passed as ``configured`` when + supported so both read the same config. """ try: from api.agent_compat import agent_attr + from api.mcp_runtime import accepts_keywords, filter_runtime_status_to_view get_mcp_status = agent_attr("tools.mcp_tool", "get_mcp_status", "tools.mcp_tool_discovery") - statuses = get_mcp_status() + if isinstance(servers, dict) and accepts_keywords(get_mcp_status, "configured"): + # Invalid entries are summarized as invalid_config by WebUI; keep them + # out of the agent call so one bad entry cannot blank every status. + statuses = get_mcp_status(configured={ + str(name): scfg for name, scfg in servers.items() if isinstance(scfg, dict) + }) + else: + statuses = get_mcp_status() + if view is not None and isinstance(statuses, list): + statuses = filter_runtime_status_to_view(statuses, view) except Exception: return {} if not isinstance(statuses, list): @@ -29478,10 +29644,18 @@ def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries): return tools -def _mcp_tools_from_registry(server_summaries): - """Read already-registered MCP tool schemas without probing MCP servers.""" +def _mcp_tools_from_registry(server_summaries, view=None): + """Read already-registered MCP tool schemas without probing MCP servers. + + With a profile ``view`` (see ``api.mcp_runtime``), only tools registered in + that profile's own registry slot are listed. The slot is the isolation check; + the raw ``mcp_servers`` config is not an allowlist: the agent merges portable + plugin servers into the running config at runtime, and their tools are + registered in the same slot without a ``config.yaml`` entry. + """ try: from tools.registry import registry + from api.mcp_runtime import registry_tool_owned_by_view except Exception: return [] tools = [] @@ -29497,6 +29671,10 @@ def _mcp_tools_from_registry(server_summaries): if not isinstance(toolset, str) or not toolset.startswith("mcp-"): continue server_name = toolset[len("mcp-"):] + if view is not None and not view.legacy and not registry_tool_owned_by_view( + registry, tool_name, view + ): + continue schema = registry.get_schema(tool_name) or {} server_summary = server_summaries.get(server_name, { "name": server_name, @@ -29508,22 +29686,45 @@ def _mcp_tools_from_registry(server_summaries): return tools +def _mcp_profile_runtime_inventory(servers, purpose, *, include_tools=True): + """Build server summaries and tools from ONE runtime view of the request profile. + + Status, tool count and inventory are all read inside the same + ``mcp_runtime_scope()`` so they describe the same profile's connections. + When that profile scope cannot be confirmed, runtime data is withheld rather + than showing another profile's connection. Passive: never starts or probes + MCP servers. + """ + from api.mcp_runtime import mcp_runtime_scope + + runtime = {} + tools = [] + source = "none" + with mcp_runtime_scope(purpose) as view: + if view.trusted: + runtime = _mcp_runtime_status_by_name(servers, view) + server_summaries = { + str(name): _server_summary(str(name), scfg, runtime.get(str(name))) + for name, scfg in servers.items() + } + if include_tools and view.trusted: + tools = _mcp_tools_from_runtime_status(runtime, server_summaries) + source = "mcp_runtime_status" + if not tools: + tools = _mcp_tools_from_registry(server_summaries, view) + source = "tool_registry" if tools else "none" + return server_summaries, tools, source, view.scope_label + + def _handle_mcp_tools_list(handler): """List known MCP tools from already-available runtime inventory only.""" cfg = get_config_for_profile_home(get_active_hermes_home()) servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} - runtime = _mcp_runtime_status_by_name() - server_summaries = { - str(name): _server_summary(str(name), scfg, runtime.get(str(name))) - for name, scfg in servers.items() - } - tools = _mcp_tools_from_runtime_status(runtime, server_summaries) - source = "mcp_runtime_status" - if not tools: - tools = _mcp_tools_from_registry(server_summaries) - source = "tool_registry" if tools else "none" + server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory( + servers, "/api/mcp/tools" + ) tools.sort(key=lambda row: (row.get("server", ""), row.get("name", ""))) unavailable_servers = [ summary["name"] for summary in server_summaries.values() @@ -29534,6 +29735,7 @@ def _handle_mcp_tools_list(handler): "total": len(tools), "source": source, "inventory_scope": "already_known_runtime_only", + "runtime_scope": runtime_scope, "unavailable_servers": unavailable_servers, }) @@ -29723,21 +29925,15 @@ def _handle_notes_sources_list(handler): servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} - runtime = _mcp_runtime_status_by_name() - server_summaries = { - str(name): _server_summary(str(name), scfg, runtime.get(str(name))) - for name, scfg in servers.items() - } - tools = _mcp_tools_from_runtime_status(runtime, server_summaries) - source = "mcp_runtime_status" - if not tools: - tools = _mcp_tools_from_registry(server_summaries) - source = "tool_registry" if tools else "none" + server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory( + servers, "/api/notes/sources" + ) return j(handler, { "enabled": True, "sources": _notes_sources_from_mcp_inventory(server_summaries, tools), "source": source, "inventory_scope": "already_known_runtime_only", + "runtime_scope": runtime_scope, "attach_supported": False, "automatic_recall_unchanged": True, "recent_ai_notes": _joplin_recent_ai_notes(limit=6), @@ -30028,15 +30224,14 @@ def _handle_mcp_servers_list(handler): servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} - runtime = _mcp_runtime_status_by_name() - result = [ - _server_summary(name, scfg, runtime.get(str(name))) - for name, scfg in servers.items() - ] + server_summaries, _tools, _source, runtime_scope = _mcp_profile_runtime_inventory( + servers, "/api/mcp/servers", include_tools=False + ) return j(handler, { - "servers": result, + "servers": list(server_summaries.values()), "toggle_supported": True, "reload_required": True, + "runtime_scope": runtime_scope, }) diff --git a/api/run_journal.py b/api/run_journal.py index 48cd8ef7..231d6699 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -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), } diff --git a/api/session_recovery.py b/api/session_recovery.py index d2d46cb2..d9b09479 100644 --- a/api/session_recovery.py +++ b/api/session_recovery.py @@ -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, diff --git a/api/streaming.py b/api/streaming.py index dd15620e..489c91a8 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -75,7 +75,11 @@ from api.turn_journal import append_turn_journal_event_for_stream from api.usage import prompt_cache_hit_percent from api.models import ( StateDBSessionMessagesSnapshot, + _WEBUI_TRUSTED_AGENT_INPUT_FIELD, _is_empty_partial_activity_message, + _message_exact_timestamp_details, + _message_private_identity_compatible, + _validated_webui_pending_user_timestamp_identity, _evict_sessions_over_cap, clear_process_wakeup_pause, get_state_db_session_messages, @@ -891,6 +895,9 @@ 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 ) ) @@ -2102,6 +2109,58 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all return messages, True +def _active_turn_user_text_matches(message, expected_text): + """Validate the Agent's declared user row against the submitted prompt.""" + if not isinstance(message, dict) or message.get('role') != 'user' or not isinstance(expected_text, str): + return False + content = message.get('content') + if isinstance(content, list): + leading_text = [] + for part in content: + if not isinstance(part, dict): + return False + part_type = str(part.get('type') or '').lower() + if part_type in {'image', 'image_url', 'input_image'}: + break + if part_type not in {'', 'text', 'input_text', 'output_text'}: + return False + text = part.get('text', part.get('input_text', part.get('output_text', ''))) + if not isinstance(text, str): + return False + leading_text.append(text) + if not content: + return False + actual_text = '\n'.join(leading_text) + elif isinstance(content, str): + actual_text = content + else: + return False + if ( + isinstance(content, str) + and " ".join(actual_text.split()) == " ".join(expected_text.split()) + ): + return True + return _submitted_user_text_matches(actual_text, expected_text) + + +def _submitted_user_text_matches(actual_text, expected_text): + """Match exact prompt text with at most its generated workspace prefix.""" + if actual_text == expected_text: + return True + if not isinstance(actual_text, str) or not isinstance(expected_text, str): + return False + if not actual_text.endswith(expected_text): + return False + prefix = actual_text[:-len(expected_text)] if expected_text else actual_text + return bool( + prefix + and ( + _WORKSPACE_PREFIX_RE.fullmatch(prefix) + or _LEGACY_WORKSPACE_PREFIX_RE.fullmatch(prefix) + ) + ) + + def _owner_projection_current_turn_row(messages, identity): messages = list(messages or []) if not isinstance(identity, dict): @@ -2138,16 +2197,19 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi return idx if not _active_turn_boundary_is_valid(identity): return None - expected_text = identity.get('text') if identity.get('text') is not None else msg_text idx = identity['current_turn_user_idx'] if idx < 0 or idx >= len(result_messages): return None message = result_messages[idx] + expected_text = identity.get('text') if identity.get('text') is not None else msg_text + if _active_turn_user_text_matches(message, expected_text): + return idx + trusted_agent_input = identity.get('trusted_agent_input_text') if ( - isinstance(message, dict) - and message.get('role') == 'user' - and _normalize_user_text(_message_text(message.get('content'))) - == _normalize_user_text(expected_text) + identity.get('text') == msg_text + and isinstance(trusted_agent_input, str) + and trusted_agent_input != expected_text + and _active_turn_user_text_matches(message, trusted_agent_input) ): return idx return None @@ -2236,17 +2298,19 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m ) if _checkpoint_idx is not None: existing_checkpoint = result_messages[_checkpoint_idx] - if isinstance(identity.get('checkpoint'), dict): - retained_checkpoint = _materialize_active_turn_user(identity, msg_text, source) - if ( - retained_checkpoint.get('id') is None - and isinstance(existing_checkpoint, dict) - and existing_checkpoint.get('id') is not None - ): - retained_checkpoint['id'] = existing_checkpoint['id'] - result_messages[_checkpoint_idx] = retained_checkpoint - else: - _mark_active_turn_checkpoint(existing_checkpoint, identity) + _mark_active_turn_checkpoint(existing_checkpoint, identity) + checkpoint = identity.get('checkpoint') + if isinstance(checkpoint, dict): + for key in ('id', 'timestamp'): + if existing_checkpoint.get(key) is None and checkpoint.get(key) is not None: + existing_checkpoint[key] = copy.deepcopy(checkpoint[key]) + if checkpoint.get('attachments'): + existing_checkpoint['attachments'] = copy.deepcopy(checkpoint['attachments']) + stamp_message_source( + existing_checkpoint, + identity.get('source') or source or 'webui', + active_turn_token=identity.get('token'), + ) return result_messages previous_context = list(previous_context or []) if _messages_have_prefix(result_messages, previous_context): @@ -2316,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), @@ -2428,6 +2500,50 @@ def _settle_result_messages( source=source, verification_nudge_provenance=verification_nudge_provenance, ) + trusted_agent_input = ( + active_turn_identity.get('trusted_agent_input_text') + if isinstance(active_turn_identity, dict) + else None + ) + if ( + isinstance(trusted_agent_input, str) + and active_turn_identity.get('text') == msg_text + and active_turn_identity.get('token') + ): + current_display_rows = [ + message for message in session.messages + if _active_turn_token_matches(message, active_turn_identity) + ] + current_context_rows = [ + message for message in session.context_messages + if _active_turn_token_matches(message, active_turn_identity) + ] + if ( + len(current_display_rows) == 1 + and current_display_rows[0].get('content') == msg_text + and len(current_context_rows) == 1 + ): + context_user = current_context_rows[0] + context_content = context_user.get('content') + if ( + isinstance(context_content, list) + and any( + isinstance(part, dict) + and str(part.get('type') or '').lower() + in {'image', 'image_url', 'input_image'} + for part in context_content + ) + and _active_turn_user_text_matches( + context_user, + trusted_agent_input, + ) + ): + 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 @@ -2455,6 +2571,61 @@ def _current_turn_already_has_visible_assistant_answer(messages, *, active_turn_ return False +def _trusted_native_image_context_display_mirror_tokens(previous_display, previous_context): + """Find unique token-paired rich image rows already represented in display.""" + display_by_token = {} + context_by_token = {} + for messages, rows_by_token in ( + (previous_display, display_by_token), + (previous_context, context_by_token), + ): + for message in messages or []: + if not isinstance(message, dict) or message.get('role') != 'user': + continue + token = message.get('_active_turn_token') + if not isinstance(token, str) or not token: + continue + rows_by_token.setdefault(token, []).append(message) + + mirrored_tokens = set() + for token, context_rows in context_by_token.items(): + display_rows = display_by_token.get(token, []) + if len(context_rows) != 1 or len(display_rows) != 1: + continue + context, display = context_rows[0], display_rows[0] + content = context.get('content') + if ( + not isinstance(content, list) + or not any( + isinstance(part, dict) + and str(part.get('type') or '').lower() in {'image', 'image_url', 'input_image'} + for part in content + ) + or not isinstance( + context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD), + str, + ) + or not _active_turn_user_text_matches( + context, + context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD), + ) + or not _message_private_identity_compatible(display, context) + ): + continue + display_ts, display_ts_valid = _message_exact_timestamp_details(display) + context_ts, context_ts_valid = _message_exact_timestamp_details(context) + if ( + not display_ts_valid + or not context_ts_valid + or display_ts is None + or context_ts is None + or display_ts != context_ts + ): + continue + mirrored_tokens.add(token) + return mirrored_tokens + + def _agent_result_tool_limit_reached(result) -> bool: """Return True when current-turn metadata says the tool iteration cap fired.""" if not isinstance(result, dict): @@ -5590,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 @@ -7426,6 +7685,46 @@ def _merge_display_messages_after_agent_result( result_messages = _drop_synthetic_control_messages(result_messages) if not result_messages: return previous_display + active_turn_row_index = _find_active_turn_checkpoint_index( + result_messages, previous_context, _active_turn_identity, msg_text, + ) + active_turn_row = ( + result_messages[active_turn_row_index] + if active_turn_row_index is not None + else None + ) + active_turn_display_text = None + active_content = active_turn_row.get('content') if isinstance(active_turn_row, dict) else None + if ( + isinstance(active_turn_row, dict) + and active_turn_row.get('role') == 'user' + and isinstance(active_content, list) + and any( + isinstance(part, dict) + and isinstance(part.get('type'), str) + and part.get('type') in {'image', 'image_url', 'input_image'} + for part in active_content + ) + ): + active_turn_display_text = ( + _active_turn_identity.get('text') + if _active_turn_identity.get('text') is not None + else msg_text + ) + if not isinstance(active_turn_display_text, str): + active_turn_display_text = None + else: + for display_message in previous_display: + if _active_turn_token_matches(display_message, _active_turn_identity): + display_message['content'] = active_turn_display_text + display_message.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if display_message.get(key) is None and active_turn_row.get(key) is not None: + display_message[key] = copy.deepcopy(active_turn_row[key]) + if not display_message.get('attachments') and active_turn_row.get('attachments'): + display_message['attachments'] = copy.deepcopy( + active_turn_row['attachments'] + ) previous_user_tail = _stale_user_tail_candidate(_last_user_row(previous_context)) # ── Backfill normal turns from previous_context that are missing from @@ -7440,16 +7739,34 @@ def _merge_display_messages_after_agent_result( # at/after a cursor. Any context messages between the cursor and that # match are context-only gaps that get spliced in before the display msg. if previous_display and previous_context: + _displayed_native_image_context_tokens = ( + _trusted_native_image_context_display_mirror_tokens( + previous_display, + previous_context, + ) + ) _display_id_set = {_message_identity(m) for m in previous_display} _context_id_set = { _message_identity(m) for m in previous_context + if not ( + isinstance(m, dict) + and m.get('_active_turn_token') in _displayed_native_image_context_tokens + ) if not _is_context_compression_marker(m) and not _is_compressed_context_tool_result_summary_message(m) } _has_context_only_turns = bool(_context_id_set - _display_id_set) if _has_context_only_turns: - context_keys = [_message_identity(m) for m in previous_context] + context_keys = [ + None + if ( + isinstance(m, dict) + and m.get('_active_turn_token') in _displayed_native_image_context_tokens + ) + else _message_identity(m) + for m in previous_context + ] # Precompute display keys once; avoids repeated json.dumps calls inside # the inner any() loop (was O(D²·C) — see perf fix below). _display_keys = [_message_identity(m) for m in previous_display] @@ -7649,10 +7966,55 @@ def _merge_display_messages_after_agent_result( or _is_compressed_context_tool_result_summary_message(msg) ): continue + is_active_image_row = ( + active_turn_display_text is not None + and isinstance(msg, dict) + and msg.get('role') == 'user' + and ( + msg is active_turn_row + or _active_turn_token_matches(msg, _active_turn_identity) + ) + ) + if is_active_image_row: + display_row = _materialize_active_turn_user( + _active_turn_identity, + active_turn_display_text, + source, + ) + display_row['content'] = active_turn_display_text + display_row.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if display_row.get(key) is None and msg.get(key) is not None: + display_row[key] = copy.deepcopy(msg[key]) + if not display_row.get('attachments') and msg.get('attachments'): + display_row['attachments'] = copy.deepcopy(msg['attachments']) + _mark_active_turn_checkpoint(display_row, _active_turn_identity) + existing_idx = next( + ( + idx for idx, existing in enumerate(merged) + if _active_turn_token_matches(existing, _active_turn_identity) + ), + None, + ) + if existing_idx is not None: + existing = merged[existing_idx] + existing['content'] = active_turn_display_text + existing.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if existing.get(key) is None and msg.get(key) is not None: + existing[key] = copy.deepcopy(msg[key]) + if not existing.get('attachments') and msg.get('attachments'): + existing['attachments'] = copy.deepcopy(msg['attachments']) + if not existing.get('attachments') and display_row.get('attachments'): + existing['attachments'] = copy.deepcopy(display_row['attachments']) + _mark_active_turn_checkpoint(existing, _active_turn_identity) + continue + msg = display_row key = _message_identity(msg) is_current_user_turn = _looks_like_current_user_turn(msg, msg_text) if ( - ((key is not None and key == current_user_key) or is_current_user_turn) + not is_active_image_row + and ((key is not None and key == current_user_key) or is_current_user_turn) and merged and ( _message_identity(merged[-1]) == current_user_key @@ -7692,12 +8054,14 @@ def _merge_display_messages_after_agent_result( continue display_msg = msg if ( - ((key is not None and key == current_user_key) or is_current_user_turn) + ((key is not None and key == current_user_key) or is_current_user_turn or is_active_image_row) and isinstance(msg, dict) and msg.get('role') == 'user' ): display_msg = copy.deepcopy(msg) - display_msg['content'] = msg_text + display_msg['content'] = ( + active_turn_display_text if is_active_image_row else msg_text + ) stamp_message_source(display_msg, source) merged.append(copy.deepcopy(display_msg)) if key is not None: @@ -8903,6 +9267,39 @@ def _add_supported_run_conversation_kwarg(callable_obj, kwargs, name, value): return False +def _register_pending_user_timestamp_identity( + callable_obj, session, persist_user_timestamp, +): + """Persist the pending-turn proof while the caller owns the session lock.""" + if session is None: + return + try: + timestamp_parameter = inspect.signature(callable_obj).parameters.get( + "persist_user_timestamp" + ) + except (TypeError, ValueError): + timestamp_parameter = None + has_timestamp_contract = ( + timestamp_parameter is not None + and timestamp_parameter.kind is not inspect.Parameter.POSITIONAL_ONLY + ) + identity = ( + _validated_webui_pending_user_timestamp_identity( + session, + (getattr(session, "active_stream_id", None), persist_user_timestamp), + ) + if has_timestamp_contract + else None + ) + previous = getattr(session, "_webui_pending_user_timestamp_identity", None) + session._webui_pending_user_timestamp_identity = identity + session_id = getattr(session, "session_id", None) + save = getattr(session, "save", None) + if callable(save) and session_id and previous != identity: + # Persist before run_conversation can write this turn to state.db. + save(touch_updated_at=False, skip_index=True) + + def _build_run_conversation_kwargs( callable_obj, *, @@ -8914,7 +9311,7 @@ def _build_run_conversation_kwargs( persist_user_message, persist_user_timestamp, ): - """Build one rolling-compatible Agent invocation contract. + """Build one rolling-compatible Agent invocation contract without mutation. ``persist_user_timestamp`` is signature-gated (#6935): an older hermes-agent whose ``run_conversation()`` predates the kwarg must not @@ -9760,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 @@ -9780,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: @@ -9809,6 +10238,7 @@ def _run_agent_streaming( 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 @@ -10134,14 +10564,11 @@ def _run_agent_streaming( # loaded the default profile's `mcp_servers`, even when the session # was stamped with a non-default profile. See issue #1968. # - # NOTE: `_servers` in `tools/mcp_tool.py` is a process-global registry - # keyed by server name. This means once profile A registers a server - # named e.g. `postgres`, profile B's discovery sees it as already - # connected and skips it — even if B's config points at a different - # binary. Fully fixing multi-profile concurrent use requires keying - # `_servers` by `(profile_home, name)` upstream in hermes-agent; that - # lives outside this WebUI repo. This change fixes the headline bug - # for users who run a single non-default profile per WebUI process. + # NOTE: hermes-agent keys MCP connections by `(profile_home, name)` only + # for a routed profile (override != process home). The HERMES_HOME + # mirror above would make this turn's own profile look like the process + # profile, falling back to bare, cross-profile names, unless the agent + # honours the pin from `api.profiles._pin_process_profile_home()`. try: from api.agent_compat import agent_attr discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery") @@ -10356,6 +10783,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() @@ -10399,6 +10828,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 @@ -11494,6 +11925,11 @@ def _run_agent_streaming( _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), profile=(getattr(s, "profile", None) or Path(_profile_home))) _persistent_state_before = _persistent_state_snapshot(_profile_home) + with _agent_lock: + _persist_user_timestamp = getattr(s, 'pending_started_at', None) + _register_pending_user_timestamp_identity( + agent.run_conversation, s, _persist_user_timestamp + ) _run_conversation_kwargs = _build_run_conversation_kwargs( agent.run_conversation, user_message=user_message, @@ -11509,7 +11945,7 @@ def _run_agent_streaming( conversation_history_revision=_conversation_history_revision, task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_persist_user_timestamp, ) # Only pass moa_config when a /moa override is actually active, so a # normal send never trips a TypeError on an older hermes-agent whose @@ -11551,6 +11987,11 @@ def _run_agent_streaming( profile=(getattr(s, "profile", None) or Path(_profile_home)), ) _run_conversation_kwargs["user_message"] = user_message + # This exact WebUI-composed input is identity evidence for the + # Agent-indexed current row. Keep identity['text'] as the clean + # submitted prompt for display. Assignment follows notification + # rejection/rebuild so rejected text cannot claim a returned row. + _active_turn_identity['trusted_agent_input_text'] = _agent_msg_text _result_partial_pre_call_context = list(_previous_context_messages) if not _agent_can_invoke(agent): with _agent_lock: @@ -11601,6 +12042,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 @@ -12068,6 +12514,15 @@ def _run_agent_streaming( _heal_context_messages, _heal_conversation_history_revision, ) = _refresh_context_and_revision_from_state_db() + _heal_persist_user_timestamp = getattr( + s, 'pending_started_at', None + ) + # The returned-error path already owns _agent_lock. + _register_pending_user_timestamp_identity( + agent.run_conversation, + s, + _heal_persist_user_timestamp, + ) _heal_kwargs = _build_run_conversation_kwargs( agent.run_conversation, user_message=user_message, @@ -12085,7 +12540,7 @@ def _run_agent_streaming( ), task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_heal_persist_user_timestamp, ) if moa_config is not None: _heal_kwargs["moa_config"] = moa_config @@ -12303,6 +12758,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: @@ -12445,13 +12902,9 @@ def _run_agent_streaming( if attachments: display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)] for m in reversed(s.messages): - if m.get('role') == 'user': - content = str(m.get('content', '')) - # Match if content is part of the sent message or vice-versa - base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text - if base_text[:60] in content or content[:60] in msg_text: - m['attachments'] = display_attachments - break + if _active_turn_token_matches(m, _active_turn_identity): + m['attachments'] = display_attachments + break # Persist reasoning trace in the session so it survives reload. # Must run BEFORE s.save() — otherwise the mutation lives only in # memory until the next turn's save, and the last-turn thinking card @@ -12509,7 +12962,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 []) @@ -13395,6 +13850,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, @@ -13423,6 +13881,15 @@ def _run_agent_streaming( _heal_context_messages, _heal_conversation_history_revision, ) = _refresh_context_and_revision_from_state_db() + with _agent_lock: + _heal_persist_user_timestamp = getattr( + s, 'pending_started_at', None + ) + _register_pending_user_timestamp_identity( + _heal_agent.run_conversation, + s, + _heal_persist_user_timestamp, + ) _heal_kwargs2 = _build_run_conversation_kwargs( _heal_agent.run_conversation, user_message=user_message, @@ -13440,7 +13907,7 @@ def _run_agent_streaming( ), task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_heal_persist_user_timestamp, ) if moa_config is not None: _heal_kwargs2["moa_config"] = moa_config @@ -13522,6 +13989,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, diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index e6248e03..5c728179 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -59,6 +59,41 @@ proposes. It is routed in `api/routes.py` and implemented by is a global invalidation signal, not a per-session lifecycle stream. The proposed `GET /api/sessions/{session_id}/events` is per-session and path-distinct. +### Hidden-tab observation and recovery (implemented client behavior) + +The browser closes its persistent per-session SSE while hidden and uses +`_startHiddenActiveStreamPoll()` in `static/messages.js` to poll +`GET /api/session/status?session_id=...` immediately and then every six seconds, +subject to browser timer throttling. An active stream can be attached through +the existing replay path; successful attachment stops the poll. + +HTTP `404` is ambiguous: older profile-visibility guards and the legacy +unknown-profile path can return it for a live session. Current master returns +`409 session_profile_mismatch` for a known foreign profile; that response +remains retryable when another tab changes the browser-wide profile cookie. +A single `404` therefore keeps polling and retains the hidden-resume owner. +After three consecutive `404` responses, the poll pauses to bound repeated +missing-session requests, but retains that owner so returning to the visible +tab can reopen SSE. Any other response or network error resets this budget; +a newly started poll also starts with a fresh budget. + +HTTP `410` is terminal: it stops the interval and clears the matching +hidden-resume session ID. Returning to the visible tab then does not reopen +SSE through that owner. Responses and queued ticks belong to one poll timer, +not merely a session ID: they cannot stop or attach a replacement poll, even +when the replacement observes the same session. The budget counts responses +received by the current poll. Cleanup affects browser observation state only; +it does not delete a session or cancel an agent run. + +Successful idle responses with no `active_stream_id`, network failures, and +other non-success HTTP responses (including `401`, `403`, `429`, and `5xx`) +remain retryable. Visibility return normally restores per-session SSE when a +resume owner still exists, including after repeated `404` responses. Only a +terminal `410` removes that automatic recovery path. A profile restored before +the three-miss limit can recover through the next hidden poll; after the limit, +recovery waits for visibility return or explicit session loading. Behavior coverage lives in +`tests/test_hidden_tab_server_initiated_turn.py`. + ### Heartbeat `_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (defined in `api/routes.py`) is the current @@ -162,7 +197,7 @@ gate rather than inventing values without source support. ## Authoritative emitted events (`/api/chat/stream`) These are the **real wire `event:` names** emitted by `api/streaming.py` today -(23 names). Clients and docs must use this table — not the semantic draft above — +(24 names). Clients and docs must use this table — not the semantic draft above — when integrating with the live chat SSE relay. | Wire name | Role | @@ -179,6 +214,7 @@ when integrating with the live chat SSE relay. | `title` | Session title update (often after `done`) | | `title_status` | Title generation status / skip reason | | `warning` | Non-fatal provider/fallback warning | +| `runtime_model` | Local Agent serving identity observed at output or successful completion | | `apperror` | Terminal application error (no trailing `stream_end`) | | `cancel` | Run cancelled | | `done` | Turn finalized (session payload); title/`stream_end` may follow | @@ -199,6 +235,31 @@ The semantic taxonomy table remains a draft for the proposed per-session endpoint vocabulary and must be confirmed during maintainer review before that endpoint claims parity. +### Observed local runtime model + +The local worker emits `runtime_model` with +`{session_id, stream_id, model, provider?, fallback_active, phase}`. The IDs +refer to the original run-journal owner even if compression rotates the Agent's +session. `phase="observed_output"` means the Agent's own model was read at a +nonempty token/reasoning callback or after successful completion (including a +non-streaming reply or credential self-heal). It does not promise that a turn +which emitted partial output will finish successfully. Missing Agent model +sends no observation; the configured selection is not used as serving proof. +`fallback_active` is true only when the Agent explicitly reports its fallback +flag. Consecutive identical observations are deduplicated within the turn. + +Fallback lifecycle warnings invalidate older serving evidence and reset the +deduplication; status text alone never establishes the replacement model. A +fresh observation after the warning reestablishes it, including when a buffered +success notice arrives after output. Durable replay retains the event IDs. +The journal summary and HTTP `runtime_journal_snapshot.runtime_model` project +the latest valid observation from the same session/stream event window, or null +if a warning or malformed latest observation invalidated it. No previous run's +footer or selected route fills an unknown current run. This is a local-worker +producer; gateway attribution and frontend presentation are separate concerns +(see #6272 and #7181). `route_observed` remains reserved for route-only data, +not successful output. + ## Cursor and resume semantics `Last-Event-ID` is the standard SSE reconnect header. Clients send the last diff --git a/docs/rfcs/webui-pending-intent-controls.md b/docs/rfcs/webui-pending-intent-controls.md index d8c3e65a..d2195e74 100644 --- a/docs/rfcs/webui-pending-intent-controls.md +++ b/docs/rfcs/webui-pending-intent-controls.md @@ -215,6 +215,14 @@ settle. Steer must not exist only as a toast or transient DOM state. It must survive refresh, session switch, replay, and settled render. +**The transport wrapper is not the Steer row.** A Steer is emitted as a standalone +typed user row (`role: user`, `display_kind: "steer"`). The surrounding +`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block is transport-level +control data: settlement unwraps the single validated frame in place, extracting +and preserving the user's authored steer instruction while dropping the wrapper. +Legacy tool rows and rows where markers are multiple, nested, incomplete, or +contain ambiguous delimiters are preserved byte-for-byte. + ### Relationship to system control events Steer is similar to system-delivered control events such as tool-iteration-limit diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 7df7f1a0..919ed855 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -171,7 +171,41 @@ and 5; it does not mark every run-state boundary implemented. do not conflict, and the pairing is unambiguous. Keep the rich sidecar row; if any requirement is missing or contradictory, preserve both rows rather than deduplicating. Literal scalar `[screenshot]` text alone is not identity - evidence. + evidence. A WebUI-submitted native-image turn has a separate display owner: + keep its exact submitted text and attachment in the visible session row, + while the Agent's expanded multipart row remains in `context_messages` for + model replay. While the turn is active, the WebUI may hide an Agent user row + from display only after its worker confirms that the active stream's exact + `pending_started_at` value was passed to the Agent as + `persist_user_timestamp`; persist that private proof with the session and + validate it against the pending stream, source, and timestamp after reload. + Never include the proof in public session payloads. This applies to + native-image and scalar text-attachment rows, and never changes model + context. If multiple user rows share that timestamp, omit the whole + ambiguous display bucket until the turn settles; keep all rows in model + context. Do not identify the row by its text. If a stream dies before + settlement, state.db self-heal must save the submitted prompt and attachments + as a visible sidecar row before clearing pending metadata only when there is + genuine state.db output beyond that submitted turn. Otherwise, leave pending + state intact for journaled partial-output and interruption-marker recovery. + The Agent row remains available in model context. A partial continuation + must use one consistent parent snapshot when projecting a conflicting + provider payload onto its sidecar-owned display row. + Match settled native-image scalar projections only with trusted turn and + durable-row identity, never the marker alone. A durable row ID proves row + identity, not provider-payload freshness: when the sidecar and state.db have + conflicting nonempty `api_content`, preserve both versions for model-context + replay without mutating either. For visible display, a marked mirror may + share the existing sidecar bubble only when its valid durable row ID, exact + timestamp, and exact visible user content match; keep the sidecar-owned row + and its display metadata. Distinct row IDs, ambiguous or invalid identities, + and different visible user text remain separate only while eligible under + the existing edit/undo truncation watermark and checkpoint-order rules; + removed rows must not reappear in display or model replay. Fill a missing + payload from the other copy; repeated reconciliation must remain bounded + and idempotent. + Agent state.db alone cannot restore the original attachment if the WebUI + sidecar is lost. Visible interim assistant progress must remain visible timeline content; a compact Activity disclosure may summarize adjacent tool/debug detail, but it must not be the only place where the user can see emitted progress text. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bb59d431..33c4c0ca 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -309,6 +309,27 @@ Interpret the two together: --- +## MCP panel shows another profile's servers, or "Live status for this profile is unavailable" + +**Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the MCP panel and the external Notes drawer show the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*. + +**Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection. + +**Diagnostic commands.** + +```bash +# runtime_scope: "profile" (bound), "legacy_process" (Agent without profile-scoped MCP), +# "unavailable" (scope could not be confirmed right now) +curl -s -b "hermes_profile=" http://127.0.0.1:8787/api/mcp/servers | python3 -m json.tool | grep -E '"(name|status|tool_count|runtime_scope)"' +python3 -c "import hermes_constants; print(hasattr(hermes_constants, 'pin_process_hermes_home'))" +``` + +**Fix.** `unavailable` while a turn is running is expected: refresh once the turn finishes. If it persists with no turn running, the Agent predates the process-home pin; upgrade Hermes Agent. `legacy_process` means the Agent has no profile-scoped MCP ledger at all; its `/reload-mcp` stays process-wide by design. After changing a profile's `mcp_servers`, run `/reload-mcp` **from that profile**: it only resets that profile's own connections and retries its failed servers. + +**When to file a bug.** File a WebUI bug if `runtime_scope` is `"profile"` and a server is still reported *Active* with tools you cannot see in the inventory, or if `/reload-mcp` from one profile changes another profile's `tool_count`. + +--- + ## Other troubleshooting This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**. diff --git a/static/boot.js b/static/boot.js index 06c3888d..8a5422a9 100644 --- a/static/boot.js +++ b/static/boot.js @@ -59,6 +59,7 @@ async function cancelStream(reason){ else setStatus(''); // /api/chat/cancel only exposes `cancelled:bool`, so we cannot // distinguish reasons — keep the toast generic and short. + if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid); if(typeof showToast==='function') showToast('Stream is no longer active',2000); } return respOk; @@ -91,6 +92,9 @@ async function cancelSessionStream(session){ if(typeof setComposerStatus==='function') setComposerStatus(''); else setStatus(''); } + if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid); + if(typeof stopApprovalPollingForSession==='function') stopApprovalPollingForSession(sid); + if(typeof stopClarifyPollingForSession==='function') stopClarifyPollingForSession(sid); if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){ stopApprovalPolling(); hideApprovalCard(true); @@ -2388,9 +2392,29 @@ window._isImeEnter=_isImeEnter; function _hasFinePointerCoexisting(){ try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; } } +// Detect phone software keyboards without undoing #3076's hardware-input +// guard for tablets. Some iOS Safari versions report (any-pointer:fine) on a +// plain iPhone, so phone UAs bypass that unreliable signal. Tablets and +// touch-capable desktop UAs still require a coarse pointer with no fine pointer. +function _isTouchOnlyDevice(){ + const ua=navigator.userAgent||''; + if(/iPhone|iPod/i.test(ua)) return true; + if(/Android.*Mobile/i.test(ua)) return true; + try{ + return matchMedia('(pointer:coarse)').matches&&!_hasFinePointerCoexisting(); + }catch(_){} + return false; +} function _isNumpadEnter(e){ return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD); } +// Initialise _sendKey synchronously from the localStorage cache so the keydown +// handler below has the correct value before the async /api/settings call +// (line ~3231) resolves. Without this, on slow mobile networks the race window +// leaves _sendKey=undefined, _mobileDefault evaluates false, and plain Enter +// falls through to the `else { send() }` branch — sending the message instead +// of inserting a newline (issue: mobile Enter sends on fresh page load). +try{ window._sendKey=localStorage.getItem('hermes-pref-send_key')||'enter'; }catch(_){ window._sendKey='enter'; } $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); @@ -2424,9 +2448,8 @@ $('msg').addEventListener('keydown',e=>{ if(e.key==='Enter'){ if(_isImeEnter(e)){return;} const isNumpadEnter=_isNumpadEnter(e); - const _mobileDefault=matchMedia('(pointer:coarse)').matches - &&!_hasFinePointerCoexisting() - &&window._sendKey==='enter'; + const _mobileDefault=_isTouchOnlyDevice() + &&(window._sendKey==='enter'||typeof window._sendKey==='undefined'); if(window._sendKey==='shift+enter'){ if(e.shiftKey){e.preventDefault();send();} } else if(window._sendKey==='ctrl+enter'||_mobileDefault){ diff --git a/static/commands.js b/static/commands.js index da741566..ff7950c3 100644 --- a/static/commands.js +++ b/static/commands.js @@ -930,6 +930,12 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com clearLiveToolCards(); try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); + // Restore paging signals from the (possibly full) transcript response. + // A successful /compress returns the full transcript, so the bounded + // preflight's _messagesTruncated/_oldestIdx must be reset before render + // (#7628). Same restore-before-render ordering as the settle/cancel paths. + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!data.session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=data.session._messages_offset||0; syncTopbar(); renderMessages(); await renderSessionList(); @@ -1028,14 +1034,18 @@ async function _runManualCompression(focusTopic){ // Preflight: verify the viewed session still exists before compressing. // This avoids a confusing "not found" toast when the UI is stale. try{ - const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`); + // Bounded tail: a bare preflight used to pull and re-redact the whole + // transcript before every manual compression (#7310/#7625). Session + // existence + the current tail is all this preflight needs. + const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`); if(!live||!live.session||live.session.session_id!==sid){ throw new Error('session no longer available'); } S.session=live.session; S.messages=live.session.messages||[]; S.toolCalls=live.session.tool_calls||[]; - if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false; + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!(live.session._messages_truncated); + if(typeof _oldestIdx!=='undefined') _oldestIdx=live.session._messages_offset||0; }catch(preflightErr){ if(typeof clearCompressionUi==='function') clearCompressionUi(); if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); @@ -1823,11 +1833,13 @@ async function cmdRetry(){ const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})}); if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); + // Bounded tail: a bare reload used to pull and re-redact the whole + // transcript on every /retry recovery (#7310/#7625). + const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1'); // #5924 SILENT-race guard: a session switch during the GET await must not let // this recovery apply session A's intent to whatever session is now visible. if(!S.session||S.session.session_id!==activeSid)return; - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();} $('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize(); // Re-arm the single-shot explicit-pick marker from the captured non-default // pick — but only if it's still safe at fire time (session unchanged, current @@ -1845,8 +1857,10 @@ async function cmdUndo(){ const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})}); if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} + // Bounded tail: a bare reload used to pull and re-redact the whole + // transcript on every /undo recovery (#7310/#7625). + const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1'); + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();} showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`); }catch(e){showToast(t('undo_failed')+e.message);} } diff --git a/static/i18n.js b/static/i18n.js index 269ffe43..44bb28b4 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -78,6 +78,7 @@ const LOCALES = { mcp_servers_title: 'MCP Servers', mcp_servers_desc: 'View MCP servers configured in config.yaml.', mcp_no_servers: 'No MCP servers configured.', + mcp_runtime_scope_unavailable: 'Live status for this profile is unavailable right now (a chat turn may be running). Refresh when it finishes.', mcp_add_server: '+ Add Server', mcp_field_name: 'Server Name', mcp_transport_label: 'Transport Type', @@ -1945,6 +1946,7 @@ const LOCALES = { mcp_servers_title: 'Server MCP', mcp_servers_desc: 'Visualizza i server MCP configurati in config.yaml.', mcp_no_servers: 'Nessun server MCP configurato.', + mcp_runtime_scope_unavailable: 'Lo stato in tempo reale di questo profilo non è al momento disponibile (potrebbe essere in corso un turno di chat). Aggiorna quando sarà terminato.', mcp_add_server: '+ Aggiungi Server', mcp_field_name: 'Nome Server', mcp_transport_label: 'Tipo Trasporto', @@ -3750,6 +3752,7 @@ const LOCALES = { mcp_servers_title: 'MCPサーバー', mcp_servers_desc: 'config.yaml で設定された MCP サーバーを管理します。', mcp_no_servers: 'MCPサーバーは設定されていません。', + mcp_runtime_scope_unavailable: 'このプロファイルのライブステータスは現在利用できません(チャットのターンが実行中の可能性があります)。完了後に更新してください。', mcp_add_server: '+ サーバーを追加', mcp_field_name: 'サーバー名', mcp_transport_label: 'トランスポート種別', @@ -5557,6 +5560,7 @@ const LOCALES = { mcp_servers_title: 'Серверы MCP', mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.', mcp_no_servers: 'Серверы MCP не настроены.', + mcp_runtime_scope_unavailable: 'Актуальный статус этого профиля сейчас недоступен (возможно, выполняется ход чата). Обновите, когда он завершится.', mcp_add_server: '+ Добавить сервер', mcp_field_name: 'Имя сервера', mcp_transport_label: 'Тип транспорта', @@ -7329,6 +7333,7 @@ const LOCALES = { mcp_servers_title: 'MCP 服务器', mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。', mcp_no_servers: '未配置 MCP 服务器。', + mcp_runtime_scope_unavailable: 'El estado en vivo de este perfil no está disponible ahora mismo (puede haber un turno de chat en curso). Actualiza cuando termine.', mcp_add_server: '+ 添加服务器', mcp_field_name: '服务器名称', mcp_transport_label: '传输类型', @@ -9080,6 +9085,7 @@ const LOCALES = { mcp_servers_title: 'MCP-Server', mcp_servers_desc: 'MCP-Server in config.yaml verwalten.', mcp_no_servers: 'Keine MCP-Server konfiguriert.', + mcp_runtime_scope_unavailable: 'Der Live-Status für dieses Profil ist derzeit nicht verfügbar (möglicherweise läuft gerade ein Chat-Zug). Aktualisieren Sie, wenn er abgeschlossen ist.', mcp_add_server: '+ Server hinzufügen', mcp_field_name: 'Servername', mcp_transport_label: 'Transporttyp', @@ -10825,6 +10831,7 @@ const LOCALES = { mcp_servers_title: 'MCP 服务器', mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。', mcp_no_servers: '未配置 MCP 服务器。', + mcp_runtime_scope_unavailable: '当前无法获取此配置的实时状态(可能有对话回合正在进行)。请在其完成后刷新。', mcp_add_server: '+ 添加服务器', mcp_field_name: '服务器名称', mcp_transport_label: '传输类型', @@ -12580,6 +12587,7 @@ const LOCALES = { mcp_servers_title: 'MCP 伺服器', mcp_servers_desc: '檢視 config.yaml 中設定的 MCP 伺服器。', mcp_no_servers: '未設定 MCP 伺服器。', + mcp_runtime_scope_unavailable: '目前無法取得此設定檔的即時狀態(可能有對話回合正在進行)。請在其完成後重新整理。', mcp_add_server: '+ 新增伺服器', mcp_field_name: '伺服器名稱', mcp_transport_label: '傳輸類型', @@ -15996,6 +16004,7 @@ const LOCALES = { mcp_servers_title: 'MCP Servers', mcp_servers_desc: 'Manage Model Context Protocol servers configured in config.yaml.', mcp_no_servers: 'No MCP servers configured.', + mcp_runtime_scope_unavailable: '이 프로필의 실시간 상태를 현재 확인할 수 없습니다(채팅 턴이 진행 중일 수 있음). 완료된 후 새로 고치세요.', mcp_add_server: '+ Add Server', mcp_field_name: 'Server Name', mcp_transport_label: 'Transport Type', @@ -17801,6 +17810,7 @@ const LOCALES = { mcp_servers_title: 'Serveurs MCP', mcp_servers_desc: 'Afficher les serveurs MCP configurés dans config.yaml.', mcp_no_servers: 'Aucun serveur MCP configuré.', + mcp_runtime_scope_unavailable: 'Le statut en direct de ce profil est indisponible pour le moment (un tour de conversation est peut-être en cours). Actualisez quand il sera terminé.', mcp_add_server: '+ Ajouter un serveur', mcp_field_name: 'Nom du serveur', mcp_transport_label: 'Type de transport', @@ -20909,6 +20919,7 @@ const LOCALES = { mcp_load_failed: 'Načtení serverů MCP se nezdařilo.', mcp_name_required: 'Je vyžadováno jméno serveru.', mcp_no_servers: 'Nejsou nakonfigurovány žádné servery MCP.', + mcp_runtime_scope_unavailable: 'Živý stav tohoto profilu není momentálně k dispozici (možná probíhá tah chatu). Obnovte, až skončí.', mcp_restart_hint: 'Změny serveru jsou zatím jen pro čtení. Upravte config.yaml a restartujte Hermes, aby se změny projevily.', mcp_save: 'Uložit', mcp_save_failed: 'Uložení serveru MCP se nezdařilo.', @@ -21350,6 +21361,7 @@ const LOCALES = { mcp_servers_title: 'MCP Sunucuları', mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.', mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.', + mcp_runtime_scope_unavailable: 'Bu profilin canlı durumu şu anda kullanılamıyor (bir sohbet turu çalışıyor olabilir). Bittiğinde yenileyin.', mcp_add_server: '+ Sunucu Ekle', mcp_field_name: 'Sunucu Adı', mcp_transport_label: 'Taşıma Türü', @@ -23148,6 +23160,7 @@ const LOCALES = { mcp_servers_title: 'Serwery MCP', mcp_servers_desc: 'Wyświetl serwery MCP skonfigurowane w config.yaml.', mcp_no_servers: 'Brak skonfigurowanych serwerów MCP.', + mcp_runtime_scope_unavailable: 'Stan na żywo tego profilu jest obecnie niedostępny (może trwać tura czatu). Odśwież, gdy się zakończy.', mcp_add_server: '+ Dodaj serwer', mcp_field_name: 'Nazwa serwera', mcp_transport_label: 'Typ transportu', @@ -24941,6 +24954,7 @@ const LOCALES = { mcp_servers_title: 'Máy chủ MCP', mcp_servers_desc: 'Xem các máy chủ MCP được cấu hình trong config.yaml.', mcp_no_servers: 'Chưa có máy chủ MCP nào được cấu hình.', + mcp_runtime_scope_unavailable: 'Trạng thái trực tiếp của hồ sơ này hiện không khả dụng (có thể một lượt trò chuyện đang chạy). Hãy làm mới khi lượt đó kết thúc.', mcp_add_server: '+ Thêm máy chủ', mcp_field_name: 'Tên máy chủ', mcp_transport_label: 'Loại kết nối', diff --git a/static/index.html b/static/index.html index b95528e8..335868de 100644 --- a/static/index.html +++ b/static/index.html @@ -592,7 +592,7 @@ - +