2026-03-31 03:40:19 +00:00
"""
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
Hermes Web UI - - SSE streaming engine and agent thread runner .
2026-03-31 03:40:19 +00:00
Includes Sprint 10 cancel support via CANCEL_FLAGS .
"""
2026-04-28 15:18:51 +00:00
import base64
2026-04-23 21:25:43 +00:00
import contextlib
2026-06-22 03:38:28 +00:00
import contextvars
2026-03-31 03:40:19 +00:00
import json
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
import logging
2026-04-28 15:18:51 +00:00
import mimetypes
2026-03-31 03:40:19 +00:00
import os
import queue
2026-04-15 14:50:59 +00:00
import re
2026-05-25 00:05:20 +00:00
import shlex
2026-05-18 13:22:00 +00:00
import sys
2026-05-25 00:05:20 +00:00
import subprocess
2026-03-31 03:40:19 +00:00
import threading
import time
import traceback
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
import copy
2026-03-31 03:40:19 +00:00
from pathlib import Path
2026-04-15 14:50:59 +00:00
from typing import Optional
2026-03-31 03:40:19 +00:00
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger = logging . getLogger ( __name__ )
2026-03-31 03:40:19 +00:00
from api . config import (
2026-05-10 08:05:50 +00:00
get_config ,
2026-04-23 18:16:59 +00:00
STREAMS , STREAMS_LOCK , CANCEL_FLAGS , AGENT_INSTANCES , STREAM_PARTIAL_TEXT ,
2026-04-30 23:24:29 +00:00
STREAM_REASONING_TEXT , STREAM_LIVE_TOOL_CALLS ,
2026-05-09 07:08:13 +00:00
STREAM_GOAL_RELATED , PENDING_GOAL_CONTINUATION ,
stage-364: Opus-caught live SSE event_id fix (side-channel approach)
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.
Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
→ double-rendered output.
Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.
Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
tests: test_cancel_interrupt, test_sprint42, test_sprint51,
test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
other tests: test_smooth_text_fade, test_streaming_markdown,
test_streaming_race_fix. _wireSSE must NOT reset accumulators because
legacy reconnect doesn't replay events; only journal-replay does.
Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.
6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.
Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
2026-05-16 03:58:31 +00:00
STREAM_LAST_EVENT_ID ,
2026-05-30 16:13:46 +00:00
LOCK , SESSIONS , SESSIONS_MAX , SESSION_DIR ,
2026-03-31 03:40:19 +00:00
_get_session_agent_lock , _set_thread_env , _clear_thread_env ,
2026-05-11 00:15:00 +00:00
register_active_run , update_active_run , unregister_active_run ,
2026-04-23 21:25:43 +00:00
SESSION_AGENT_LOCKS , SESSION_AGENT_LOCKS_LOCK ,
2026-04-02 05:56:34 +00:00
resolve_model_provider ,
2026-05-07 12:25:04 +00:00
resolve_custom_provider_connection ,
2026-05-01 05:23:47 +00:00
model_with_provider_context ,
2026-05-15 20:00:30 +00:00
load_settings ,
Release v0.51.247 — Release HO (stage-q19) (#3521)
## Release v0.51.247 — Release HO (stage-q19)
Backend correctness fix.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3505 | @franksong2702 | **Reasoning effort is coerced to a level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. `openai-codex` `gpt-5` no longer gets `max` (→ `xhigh`); `o1`/`o3`/`o4` clamp to `low`/`medium`/`high`. Coercion only steps *down* (never escalates); `none`/unset preserved. The capability filter is applied across heuristic / models.dev / Copilot / LM Studio paths. |
This is the narrow, correct fix for the detection gap that #3431 tried to address by removing the chip-visibility gate (which we shelved). The chip-visibility gate is **untouched** (Codex confirmed) — `get_reasoning_status`/`_applyReasoningChip` still hide the chip for unconfirmed models.
### Review fix absorbed (Codex + self-flagged)
The first cut **dropped** a configured effort for *unrecognized* models, because capability detection returns `[]` for both "known-unsupported" and "simply-unknown" (custom providers, aggregator-rewritten ids, new releases) — that's a behavior change vs master (which sent it verbatim) and would silently disable reasoning. Fixed: an **empty** capability set now **preserves** the configured effort (provider stays the final authority; worst case = the same rejected request master already produces, i.e. no regression). Known-bad clamps return *non-empty* filtered sets, so they still degrade correctly. Nathan chose this "preserve-for-unknown" behavior. + regression test.
### Gate
- Full pytest suite: **7548 passed, 0 failed**
- ruff: CLEAN · 48 reasoning tests pass (incl. preserve-for-unknown + codex-clamp + never-escalate)
- Codex (regression): SHIP-ONLY-WITH-FIXES (unknown-model drop) → fixed → **SAFE TO SHIP**
- Verified empirically: gpt-5/codex max→xhigh, o3 max/xhigh→high, unknown high→high (preserved), none/unset preserved
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-04 02:21:26 +00:00
parse_reasoning_effort ,
coerce_reasoning_effort_for_model ,
2026-06-15 20:16:34 +00:00
_main_model_request_overrides ,
2026-03-31 03:40:19 +00:00
)
2026-05-07 01:15:53 +00:00
from api . helpers import redact_session_data , _redact_text
2026-05-23 18:31:07 +00:00
from api . compression_anchor import is_context_compression_marker , visible_messages_for_anchor
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
from api . metering import meter
2026-05-15 01:37:54 +00:00
from api . run_journal import RunJournalWriter
2026-06-04 23:46:20 +00:00
from api . todo_state import emit_todo_state
2026-05-11 07:08:51 +00:00
from api . turn_journal import append_turn_journal_event_for_stream
2026-05-19 05:51:52 +00:00
from api . usage import prompt_cache_hit_percent
2026-05-28 07:30:49 +00:00
from api . models import (
_is_empty_partial_activity_message ,
get_state_db_session_messages ,
reconciled_state_db_messages_for_session ,
)
2026-06-05 20:34:54 +00:00
from api . session_ops import mark_session_title_generated , session_has_manual_title
2026-03-31 03:40:19 +00:00
2026-06-14 18:54:47 +00:00
def _session_payload_with_full_messages ( session , * , tool_calls = None ) :
""" Return compact session metadata plus the embedded full transcript.
` ` Session . compact ( ) ` ` may intentionally use metadata - only counts from an
index / sidebar load . A settled SSE payload that embeds ` ` session . messages ` `
must report the count of that embedded transcript , otherwise completion and
reconcile paths can mistake a complete payload for a stale short window .
"""
messages = list ( getattr ( session , ' messages ' , None ) or [ ] )
raw = session . compact ( ) | {
' messages ' : messages ,
' message_count ' : len ( messages ) ,
}
if tool_calls is not None :
raw [ ' tool_calls ' ] = tool_calls
return raw
2026-06-22 10:04:55 +00:00
def _compact_for_echo_compare ( value : str ) - > str :
""" Normalize visible stream text for duplicate echo detection. """
return re . sub ( r ' \ s+ ' , ' ' , str ( value or ' ' ) )
2026-06-23 04:45:56 +00:00
def _strip_compact_echo_suffix ( value : str , suffix : str , * , search_window : int = 4096 ) - > tuple [ str , bool ] :
""" Remove ``suffix`` from ``value`` when they match after whitespace folding. """
raw = str ( value or ' ' )
candidate = _compact_for_echo_compare ( suffix )
if not raw or not candidate :
return raw , False
tail = raw [ - max ( len ( str ( suffix or ' ' ) ) * 3 , search_window ) : ]
offset = len ( raw ) - len ( tail )
for idx in range ( len ( tail ) + 1 ) :
if _compact_for_echo_compare ( tail [ idx : ] ) == candidate :
return raw [ : offset + idx ] . rstrip ( ) , True
return raw , False
2026-04-05 05:25:08 +00:00
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
# interleave their os.environ writes. This global lock serializes the env
2026-05-15 19:05:37 +00:00
# save/restore — held only briefly across the env-mutation critical section,
# NOT for the entire agent run. The agent runs outside the lock; the finally
# block re-acquires to atomically restore env vars. See narrow-lock pattern
# in _run_agent_streaming (line ~2719) and profile_env_for_background_worker
# (api/profiles.py:715).
2026-04-05 05:25:08 +00:00
_ENV_LOCK = threading . Lock ( )
2026-05-18 11:27:31 +00:00
_KEYLESS_CUSTOM_API_KEY = " dummy-key "
2026-06-22 03:38:28 +00:00
_STREAMING_CRON_PROFILE_HOME : contextvars . ContextVar [ str | None ] = contextvars . ContextVar (
" webui_streaming_cron_profile_home " ,
default = None ,
)
_STREAMING_CRONJOB_WRAPPER_INSTALLED = False
def _install_streaming_cronjob_profile_wrapper ( ) - > None :
""" Wrap the agent cronjob tool so calls run under the streaming profile.
The in - chat agent run already binds per - turn contextvars for other
session - scoped state . Cron jobs are special because ` ` cron . jobs ` ` snapshots
path constants at import time , so the model - facing ` ` cronjob ` ` tool must
enter the existing WebUI cron profile context at the tool - call boundary .
That context uses the cron - specific lock and restores the module caches as
soon as the single cron tool call returns , avoiding long - lived global path
mutation for the whole agent turn .
"""
global _STREAMING_CRONJOB_WRAPPER_INSTALLED
if _STREAMING_CRONJOB_WRAPPER_INSTALLED :
return
try :
from tools . registry import registry
except Exception :
logger . debug ( " streaming cronjob wrapper: tools registry unavailable " , exc_info = True )
return
entry = registry . get_entry ( " cronjob " )
if entry is None :
try :
import tools . cronjob_tools # noqa: F401
except Exception :
logger . debug ( " streaming cronjob wrapper: cronjob tool import failed " , exc_info = True )
entry = registry . get_entry ( " cronjob " )
if entry is None :
logger . debug ( " streaming cronjob wrapper: cronjob tool not registered " )
return
original_handler = entry . handler
if getattr ( original_handler , " _webui_streaming_profile_wrapper " , False ) :
_STREAMING_CRONJOB_WRAPPER_INSTALLED = True
return
# This relies on the agent tool executor's ``propagate_context_to_thread``
# using an unfiltered ``contextvars.copy_context()`` so this WebUI-owned
# contextvar reaches the sync cronjob handler even when the tool call runs
# on the agent's ThreadPoolExecutor worker.
def _profile_scoped_cronjob_handler ( args , * * kwargs ) :
profile_home = _STREAMING_CRON_PROFILE_HOME . get ( )
if not profile_home :
return original_handler ( args , * * kwargs )
from api . profiles import cron_profile_context_for_home
with cron_profile_context_for_home ( Path ( profile_home ) ) :
return original_handler ( args , * * kwargs )
_profile_scoped_cronjob_handler . __dict__ [ " _webui_streaming_profile_wrapper " ] = True
_profile_scoped_cronjob_handler . __dict__ [ " _webui_original_handler " ] = original_handler
registry . register (
name = entry . name ,
toolset = entry . toolset ,
schema = entry . schema ,
handler = _profile_scoped_cronjob_handler ,
check_fn = entry . check_fn ,
requires_env = entry . requires_env ,
is_async = entry . is_async ,
description = entry . description ,
emoji = entry . emoji ,
max_result_size_chars = entry . max_result_size_chars ,
dynamic_schema_overrides = entry . dynamic_schema_overrides ,
)
_STREAMING_CRONJOB_WRAPPER_INSTALLED = True
2026-06-04 22:21:41 +00:00
_PERSISTENT_MEMORY_FILES = (
( " memory " , ( " memories " , " MEMORY.md " ) ) ,
( " user " , ( " memories " , " USER.md " ) ) ,
( " soul " , ( " SOUL.md " , ) ) ,
)
def _file_signature ( path : Path ) - > tuple [ int , int ] | None :
try :
st = path . stat ( )
return ( int ( st . st_mtime_ns ) , int ( st . st_size ) )
except OSError :
return None
def _persistent_state_snapshot ( profile_home : str | None ) - > dict :
""" Capture lightweight memory/skill file signatures for save toasts. """
if not profile_home :
return { " memory " : { } , " skills " : { } }
root = Path ( profile_home )
memory = { }
for key , parts in _PERSISTENT_MEMORY_FILES :
sig = _file_signature ( root . joinpath ( * parts ) )
if sig is not None :
memory [ key ] = sig
skills = { }
skills_dir = root / " skills "
try :
for skill_md in skills_dir . rglob ( " SKILL.md " ) :
try :
rel = str ( skill_md . relative_to ( skills_dir ) ) . replace ( " \\ " , " / " )
except ValueError :
rel = str ( skill_md )
sig = _file_signature ( skill_md )
if sig is not None :
skills [ rel ] = sig
except OSError :
pass
return { " memory " : memory , " skills " : skills }
def _persistent_state_changes ( before : dict | None , after : dict | None ) - > dict :
before = before or { " memory " : { } , " skills " : { } }
after = after or { " memory " : { } , " skills " : { } }
memory_before = before . get ( " memory " ) or { }
memory_after = after . get ( " memory " ) or { }
skills_before = before . get ( " skills " ) or { }
skills_after = after . get ( " skills " ) or { }
memory_changed = any ( memory_before . get ( key ) != sig for key , sig in memory_after . items ( ) )
skills = [ ]
for rel , sig in skills_after . items ( ) :
old_sig = skills_before . get ( rel )
if old_sig == sig :
continue
name = Path ( rel ) . parent . name or Path ( rel ) . stem
skills . append ( {
" name " : name ,
" path " : rel ,
" action " : " created " if old_sig is None else " updated " ,
} )
return { " memory_saved " : memory_changed , " skills " : skills [ : 10 ] }
2026-05-18 11:27:31 +00:00
2026-06-06 05:17:27 +00:00
def _apply_profile_provider_context_to_streaming_model (
model : str | None ,
provider_context : str | None ,
profile_provider : str | None ,
profile_default_model : str | None ,
) - > tuple [ str | None , str | None , bool ] :
""" Attach profile provider context and repair stale cross-provider models. """
if provider_context or not profile_provider :
return model , provider_context , False
provider_context = profile_provider . lower ( )
if not profile_default_model :
return model , provider_context , False
from api . routes import _normalize_provider_id
profile_provider_normalized = _normalize_provider_id ( profile_provider )
model_lower = ( model or " " ) . lower ( )
2026-06-16 04:06:08 +00:00
# Only run the bare-prefix family match on un-namespaced model ids. A custom
# namespace like "gemini_cli/..." or "claude-relay/..." merely *starts with* a
# first-party token; matching it here would clobber the model to the profile
# default on the send path (the #4278 collision — the slash-qualified branch
# below routes through the fixed _normalize_provider_id instead).
if " / " not in model_lower :
for prefix in ( " gpt " , " claude " , " gemini " ) :
if model_lower . startswith ( prefix ) :
if _normalize_provider_id ( prefix ) != profile_provider_normalized :
return profile_default_model , provider_context , True
return model , provider_context , False
2026-06-06 05:17:27 +00:00
if " / " in model_lower :
slash_prefix = model_lower . split ( " / " , 1 ) [ 0 ]
if provider_context == " openai-codex " and slash_prefix == " openai " :
return profile_default_model , provider_context , True
slash_provider = _normalize_provider_id ( slash_prefix )
if (
slash_provider
and slash_provider != profile_provider_normalized
and profile_provider_normalized not in { " openrouter " , " custom " , " " }
) :
return profile_default_model , provider_context , True
return model , provider_context , False
def _apply_profile_home_context_to_streaming_model (
model : str | None ,
provider_context : str | None ,
profile_home : str | None ,
has_profile : bool ,
) - > tuple [ str | None , str | None , bool ] :
""" Apply profile provider/model context from a profile config if present. """
if not ( profile_home and has_profile and not provider_context ) :
return model , provider_context , False
try :
import yaml as _yaml_pp
_pp_cfg_path = Path ( profile_home ) / " config.yaml "
if not _pp_cfg_path . is_file ( ) :
return model , provider_context , False
_pp_cfg = _yaml_pp . safe_load ( _pp_cfg_path . read_text ( encoding = " utf-8 " ) ) or { }
if not isinstance ( _pp_cfg , dict ) :
return model , provider_context , False
_pp = ( _pp_cfg . get ( " model " , { } ) . get ( " provider " ) or " " ) . strip ( )
if not _pp :
return model , provider_context , False
_pp_default = ( _pp_cfg . get ( " model " , { } ) . get ( " default " ) or " " ) . strip ( )
return _apply_profile_provider_context_to_streaming_model (
model ,
provider_context ,
_pp ,
_pp_default ,
)
except Exception :
logger . warning ( " profile provider read failed " , exc_info = True )
return model , provider_context , False
2026-05-18 11:27:31 +00:00
def _resolve_custom_provider_runtime_overrides (
resolved_provider : str | None ,
resolved_api_key : str | None ,
resolved_base_url : str | None ,
) - > tuple [ str | None , str | None , str | None ] :
""" Return provider/key/base_url overrides for ``custom:*`` endpoints.
Hermes Agent treats named custom providers as routing hints around an
OpenAI - compatible base URL . Local OpenAI - compatible servers often run
without authentication , so a missing key should not fail before the first
request ; pass a harmless placeholder to the SDK and let the endpoint accept
it or return its own auth error .
"""
if not ( isinstance ( resolved_provider , str ) and resolved_provider . startswith ( " custom: " ) ) :
return resolved_provider , resolved_api_key , resolved_base_url
_cp_key , _cp_base = resolve_custom_provider_connection ( resolved_provider )
if not resolved_api_key and _cp_key :
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base :
resolved_base_url = _cp_base
if resolved_base_url :
# Route through the generic custom OpenAI-compatible client once the
# named provider has supplied the concrete endpoint. Keeping the
# provider as custom:<slug> would make Agent init synthesize invalid
# env-var hints like CUSTOM:SOMETHING-8000_API_KEY on keyless setups.
resolved_provider = " custom "
if not resolved_api_key :
resolved_api_key = _KEYLESS_CUSTOM_API_KEY
return resolved_provider , resolved_api_key , resolved_base_url
2026-05-10 22:51:49 +00:00
2026-06-12 07:54:48 +00:00
def _same_base_url_endpoint ( url_a : str , url_b : str ) - > bool :
""" True if two base URLs point at the same scheme+host+port endpoint.
Used to decide whether a runtime base_url is just a normalized form of the
configured one ( e . g . OpenCode - Go ' s ``/v1`` de-duplication on the same host)
versus a genuinely different endpoint ( an explicit ` ` providers . < id > . base_url ` `
override at a different host / port that must be preserved ) . Path / query are
intentionally ignored — the normalization #3895 fixes is path-only.
"""
from urllib . parse import urlsplit
try :
a = urlsplit ( ( url_a or " " ) . strip ( ) )
b = urlsplit ( ( url_b or " " ) . strip ( ) )
except Exception :
return False
_default_port = { " http " : 80 , " https " : 443 }
a_host = ( a . hostname or " " ) . lower ( )
b_host = ( b . hostname or " " ) . lower ( )
a_scheme = ( a . scheme or " " ) . lower ( )
b_scheme = ( b . scheme or " " ) . lower ( )
a_port = a . port or _default_port . get ( a_scheme )
b_port = b . port or _default_port . get ( b_scheme )
return bool ( a_host ) and a_host == b_host and a_scheme == b_scheme and a_port == b_port
2026-06-12 00:56:22 +00:00
def _runtime_preferred_base_url (
runtime_provider : dict | None ,
resolved_provider : str | None ,
configured_base_url : str | None ,
) - > str | None :
2026-06-12 07:54:48 +00:00
""" Prefer the runtime-normalized base_url, but never override an explicit
configured endpoint that points somewhere genuinely different .
The #3895 bug was that WebUI used the *configured* base_url (which can carry a
duplicated ` ` / v1 ` ` ) instead of the runtime provider ' s per-model-normalized
base_url , 404 ing OpenCode - Go . But blindly preferring the runtime URL would
clobber a legitimate ` ` providers . < id > . base_url ` ` override ( e . g . LM Studio at a
LAN IP , an OpenRouter mirror ) . So :
- no runtime URL - > keep configured
- no configured URL - > use runtime ( all we have )
- named ` ` custom : ` ` endpoint - > configured wins ( then runtime as fallback )
- same scheme + host + port - > runtime wins ( it ' s the normalized/corrected
form of the same endpoint — the #3895 case)
- different endpoint - > configured override wins ( no regression )
"""
2026-06-12 00:56:22 +00:00
runtime_base_url = None
if isinstance ( runtime_provider , dict ) :
runtime_base_url = runtime_provider . get ( " base_url " )
if not runtime_base_url :
return configured_base_url
2026-06-12 07:54:48 +00:00
if not configured_base_url :
return runtime_base_url
2026-06-12 00:56:22 +00:00
provider_id = str (
resolved_provider
or ( runtime_provider or { } ) . get ( " provider " )
or " "
) . strip ( ) . lower ( )
if provider_id . startswith ( " custom: " ) :
2026-06-12 01:06:16 +00:00
return configured_base_url or runtime_base_url
2026-06-12 07:54:48 +00:00
# An explicit configured override at a DIFFERENT endpoint must be preserved;
# only prefer the runtime URL when it's the same endpoint (path-normalized).
if _same_base_url_endpoint ( configured_base_url , runtime_base_url ) :
return runtime_base_url
return configured_base_url
2026-06-12 00:56:22 +00:00
2026-05-18 15:44:36 +00:00
def _is_fallback_lifecycle_message ( kind : str , message : str ) - > bool :
""" Return True if an agent lifecycle status should surface as a fallback warning. """
k = str ( kind or ' ' ) . strip ( ) . lower ( )
m = str ( message or ' ' ) . strip ( ) . lower ( )
return (
k == ' lifecycle '
and (
' rate limited ' in m
or ' switching to fallback ' in m
or ' falling back ' in m
or ' fallback activated ' in m
or ' trying fallback ' in m
)
)
2026-05-10 22:51:49 +00:00
def _prewarm_skill_tool_modules ( ) :
""" Import tools.skills_tool and tools.skill_manager_tool outside any lock.
First - time module imports can trigger heavy initialisation ( disk I / O ,
transitive imports , plugin discovery ) . Performing those imports while
holding ` ` _ENV_LOCK ` ` serialises every concurrent session behind the
slowest import . Prewarming ensures the modules are already in
` ` sys . modules ` ` before the lock is acquired , so the lock body only
does lightweight attribute patching .
We cannot place these at module top - level because ` ` tools . * ` ` lives
in the hermes - agent package which may not be on ` ` sys . path ` ` at
import time ( Docker volume - mount ordering ) . A dedicated helper
keeps the lazy - import try / except in one place and makes the intent
explicit .
"""
for _mod_name in ( ' tools.skills_tool ' , ' tools.skill_manager_tool ' ) :
try :
__import__ ( _mod_name )
except ImportError :
pass
2026-03-31 03:40:19 +00:00
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
try :
from run_agent import AIAgent
except ImportError :
AIAgent = None
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
def _get_ai_agent ( ) :
""" Return AIAgent class, retrying the import if the initial attempt failed.
auto_install_agent_deps ( ) in server . py may install missing packages after
this module is first imported ( common in Docker with a volume - mounted agent ) .
Re - attempting the import here picks up the newly installed packages without
requiring a server restart .
"""
global AIAgent
if AIAgent is None :
try :
from run_agent import AIAgent as _cls # noqa: PLC0415
AIAgent = _cls
except ImportError :
pass
return AIAgent
docs(troubleshooting): bake the #1695 diagnostic flow into the error message + a new troubleshooting doc
Closes #1695.
@Patrick-81 reported the bare "AIAgent not available -- check that
hermes-agent is on sys.path" error on a symlinked install (~/Programmes/hermes-agent
linked to ~/hermes-agent). The maintainer's response — three diagnostic
commands plus `pip install -e .` in the agent dir — fixed it for them.
This PR captures both halves of that learning so the next user with the
same shape doesn't have to file a new issue:
1. **Error message diagnostic block.** New helper
`_aiagent_import_error_detail()` in api/streaming.py builds a multi-line
diagnostic when the import fails, including:
- the running Python interpreter
- HERMES_WEBUI_AGENT_DIR (set value, or "(not set)")
- sys.path entries that mention hermes/agent (or "no entries mention..."
— itself a strong diagnostic signal)
- the most-common fix (`pip install -e .` in the agent dir)
- a pointer to docs/troubleshooting.md
The original error message string is preserved as the FIRST line so
existing log scrapers and docs-search keep matching.
Helper is kept as a separate function so it stays out of the hot path
until we actually need to raise — building it on every successful import
would be wasted work.
2. **New docs/troubleshooting.md.** Symptom → Why → Diagnostic commands →
Fix → When-to-file-a-bug template, with one entry to start: the
"AIAgent not available" flow Patrick-81 walked through. Future
recurring failure modes follow the same template. Required a one-line
addition to .gitignore — docs/* is gitignored with an allowlist, and
the new file needed `!docs/troubleshooting.md` to be tracked.
3. **README link.** docs/troubleshooting.md added to the `## Docs` section
so users know where to look first.
13 regression tests in tests/test_1695_aiagent_import_error_detail.py:
9 for the helper output shape (preserves original message line, includes
running python, shows HERMES_WEBUI_AGENT_DIR set/unset both ways, includes
pip-install-e hint, points at troubleshooting doc, lists relevant sys.path
entries when present, says "no entries..." when absent, output is multi-line)
plus 4 for the docs-presence regression (file exists, has the AIAgent
section, includes pip install -e ., describes the diagnostic chain with
readlink + agent/__init__.py verification).
190 streaming/aiagent tests pass after the change. ast.parse on
api/streaming.py clean.
CI failure on prior push was due to the docs/* gitignore swallowing the
new troubleshooting.md file silently — this commit adds the allowlist
entry so the file is tracked.
2026-05-05 22:01:23 +00:00
2026-05-07 01:15:53 +00:00
def _is_quota_error_text ( err_text : str ) - > bool :
""" Return True when provider text looks like quota/usage exhaustion. """
_err_lower = str ( err_text or ' ' ) . lower ( )
return (
' insufficient credit ' in _err_lower
or ' credit balance ' in _err_lower
or ' credits exhausted ' in _err_lower
or ' more credits ' in _err_lower
or ' can only afford ' in _err_lower
or ' fewer max_tokens ' in _err_lower
or ' quota_exceeded ' in _err_lower
or ' quota exceeded ' in _err_lower
or ' exceeded your current quota ' in _err_lower
# OpenAI Codex OAuth usage-exhaustion shapes (#1765).
or ' plan limit reached ' in _err_lower
or ' usage_limit_exceeded ' in _err_lower
or ' usage limit exceeded ' in _err_lower
or ' reached the limit of messages ' in _err_lower
or ' used up your usage ' in _err_lower
or ( ' plan ' in _err_lower and ' limit ' in _err_lower and ' reached ' in _err_lower )
)
2026-05-10 08:05:50 +00:00
def _clarify_timeout_seconds ( default : int = 120 ) - > int :
""" Resolve clarify timeout from config, with bounded fallback. """
try :
cfg = get_config ( )
raw = cfg . get ( " clarify " , { } ) . get ( " timeout " , default )
timeout_seconds = int ( raw )
if timeout_seconds < = 0 :
return default
return timeout_seconds
except Exception :
return default
2026-05-12 22:43:36 +00:00
_CANCEL_MARKER_PATTERNS = ( ' task cancelled ' , ' task canceled ' , ' response interrupted ' )
2026-05-18 18:07:06 +00:00
_WEBUI_PROGRESS_PROMPT = """
WebUI progress guidance :
2026-06-06 19:12:37 +00:00
- Match the normal Hermes messaging style , but do not let long tool - running WebUI turns appear silent .
- For long multi - step work that uses tools , emit brief user - visible progress updates as normal assistant content , not only as hidden reasoning .
- Before the first tool batch in a long task , say what you are about to inspect .
- After each meaningful batch of tool calls , say what you just confirmed and what you will check next before continuing with more tools .
- Do not run many independent tool batches back - to - back without visible assistant text between them when the task is still ongoing .
- Do not keep progress only in reasoning , thinking , or tool - result channels ; those are not a substitute for visible interim updates .
2026-05-16 15:36:30 +00:00
- Each update should say what you are about to check , what you just confirmed , or why the next tool call is needed .
- Keep updates concise , factual , and in the user ' s language. One or two short sentences are enough.
- Do not reveal hidden reasoning , chain - of - thought , private scratchpads , secrets , raw logs , or long tool output .
2026-06-01 04:25:35 +00:00
- Final visible assistant replies must be clear , user - facing , and in the user ' s language, not private planning notes.
2026-06-01 01:40:35 +00:00
- Do not include terse planning fragments or scratchpad shorthand in visible assistant text . Avoid fragments like " Need script " , " Need check logs " , " Need inspect email " , or " maybe invite " ; either omit them or rewrite them as clear user - facing progress .
2026-05-16 15:36:30 +00:00
- For direct answers or very short tasks , skip progress updates and answer normally .
""" .strip()
2026-05-18 12:13:28 +00:00
def _webui_surface_context_prompt ( surface_context : Optional [ dict ] ) - > str :
""" Return safe WebUI session metadata for the agent ' s ephemeral context.
Messaging gateways inject platform / channel context before each run . Browser
sessions do not have a chat platform wrapper , so provide an explicit , small
surface description here instead of relying on the model to infer where it
is running from the transcript alone .
"""
if not isinstance ( surface_context , dict ) :
return " "
lines = [
" WebUI session context: " ,
" - This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces. " ,
" - Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat. " ,
2026-05-29 16:14:00 +00:00
" - Do not copy or dump this browser transcript into external notes or durable memory by default. " ,
" - Write to external notes or durable memory only for explicit captures, durable user preferences, decisions, blockers/open issues, runbook-worthy workflows, or other clearly reusable signals; otherwise leave notes unchanged. " ,
" - When you do write or update a durable note, briefly tell the user what note/section changed so the write is reviewable. " ,
2026-05-18 12:13:28 +00:00
]
fields = (
( " source " , " Source " ) ,
( " session_id " , " Session ID " ) ,
( " profile " , " Profile " ) ,
( " workspace " , " Workspace " ) ,
)
for key , label in fields :
raw = surface_context . get ( key )
value = str ( raw ) . strip ( ) if raw is not None else " "
if value :
lines . append ( f " - { label } : { value } " )
return " \n " . join ( lines )
def _webui_ephemeral_system_prompt (
personality_prompt : Optional [ str ] ,
surface_context : Optional [ dict ] = None ,
2026-06-02 19:36:25 +00:00
config_data : Optional [ dict ] = None ,
2026-05-18 12:13:28 +00:00
) - > str :
2026-05-16 15:36:30 +00:00
""" Build WebUI-only runtime instructions that are not persisted to history. """
parts = [ ]
if personality_prompt :
parts . append ( str ( personality_prompt ) . strip ( ) )
2026-05-18 12:13:28 +00:00
surface_prompt = _webui_surface_context_prompt ( surface_context )
if surface_prompt :
parts . append ( surface_prompt )
2026-05-18 18:07:06 +00:00
parts . append ( _WEBUI_PROGRESS_PROMPT )
2026-06-02 19:36:25 +00:00
delivery_prompt = _webui_delivery_context_prompt ( config_data )
if delivery_prompt :
parts . append ( delivery_prompt )
2026-05-16 15:36:30 +00:00
return " \n \n " . join ( part for part in parts if part )
2026-05-18 13:22:00 +00:00
_SECRET_SHAPED_RE = re . compile (
r " (?i)(api[_-]?key|token|password|secret) \ s*[:=] \ s*[^ \ s]+| "
2026-05-18 18:56:28 +00:00
r " \ b(?:sk-[A-Za-z0-9_-] { 16,}|ghp_[A-Za-z0-9_] { 20,}|github_pat_[A-Za-z0-9_] { 20,}) \ b| "
2026-05-18 13:22:00 +00:00
r " [A-Za-z0-9_-] { 24,} \ .[A-Za-z0-9_-] { 16,} \ .[A-Za-z0-9_-] { 16,} "
)
def _redact_prefill_status_text ( text : str ) - > str :
""" Return a short, non-secret diagnostic string for prefill status. """
clean = _SECRET_SHAPED_RE . sub ( " [REDACTED] " , str ( text or " " ) )
return " " . join ( clean . split ( ) ) [ : 240 ]
def _valid_prefill_messages ( value ) - > list [ dict ] :
""" Normalize a prefill payload to role/content messages. """
if not isinstance ( value , list ) :
return [ ]
messages : list [ dict ] = [ ]
for item in value :
if not isinstance ( item , dict ) :
continue
role = item . get ( " role " )
content = item . get ( " content " )
if role not in { " system " , " user " , " assistant " } or not isinstance ( content , str ) or not content . strip ( ) :
continue
messages . append ( { " role " : role , " content " : content } )
return messages
def _resolve_prefill_path ( raw : str ) - > Path :
path = Path ( str ( raw ) ) . expanduser ( )
if not path . is_absolute ( ) :
try :
from api . config import _get_config_path
path = _get_config_path ( ) . parent / path
except Exception :
path = Path . cwd ( ) / path
return path
2026-05-25 00:20:28 +00:00
_PREFILL_SCRIPT_OUTPUT_LIMIT = 262_144
2026-05-28 19:26:35 +00:00
_PREFILL_CONTEXT_DEFAULT_MAX_CHARS = 12_000
def _prefill_context_max_chars ( config_data : dict ) - > int :
raw = os . getenv ( " HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS " , " " ) or str (
config_data . get ( " webui_prefill_context_max_chars " ) or " "
)
try :
value = int ( raw or _PREFILL_CONTEXT_DEFAULT_MAX_CHARS )
except Exception :
value = _PREFILL_CONTEXT_DEFAULT_MAX_CHARS
return max ( 0 , min ( value , _PREFILL_SCRIPT_OUTPUT_LIMIT ) )
def _prefill_context_char_count ( messages : list [ dict ] ) - > int :
return sum ( len ( str ( message . get ( " content " ) or " " ) ) for message in messages if isinstance ( message , dict ) )
def _budget_compacted_prefill_context ( context : dict , * , max_chars : int , char_count : int ) - > dict :
label = str ( context . get ( " label " ) or " prefill context " )
message = (
" A configured WebUI startup prefill source was available, but it exceeded "
f " the WebUI prefill context budget ( { char_count } chars > { max_chars } chars), "
" so the note/body payload was omitted from this new chat. If the user ' s "
" request depends on prior decisions, durable notes, runbooks, current "
" context, or open issues, use the available retrieval/search/note tools "
" to fetch only the relevant details before answering. "
)
return {
" status " : " loaded " ,
" source " : " budget_compacted " ,
" label " : label ,
" messages " : [ { " role " : " user " , " content " : message } ] ,
" message_count " : 1 ,
" compacted " : True ,
" original_source " : context . get ( " source " , " " ) ,
" original_message_count " : int ( context . get ( " message_count " ) or 0 ) ,
" original_char_count " : char_count ,
" max_chars " : max_chars ,
}
def _apply_prefill_context_budget ( context : dict , config_data : dict ) - > dict :
if context . get ( " status " ) != " loaded " :
return context
max_chars = _prefill_context_max_chars ( config_data )
if max_chars < = 0 :
return context
messages = context . get ( " messages " ) or [ ]
char_count = _prefill_context_char_count ( messages if isinstance ( messages , list ) else [ ] )
if char_count < = max_chars :
return context
file_raw = os . getenv ( " HERMES_PREFILL_MESSAGES_FILE " , " " ) or str ( config_data . get ( " prefill_messages_file " ) or " " )
if context . get ( " source " ) == " script " and file_raw :
fallback = _load_prefill_messages_file ( file_raw , source = " file_budget_fallback " )
fallback_messages = fallback . get ( " messages " ) if isinstance ( fallback , dict ) else [ ]
fallback_chars = _prefill_context_char_count ( fallback_messages if isinstance ( fallback_messages , list ) else [ ] )
if fallback . get ( " status " ) == " loaded " and fallback_chars < = max_chars :
fallback [ " compacted " ] = True
fallback [ " original_source " ] = context . get ( " source " , " " )
fallback [ " original_label " ] = context . get ( " label " , " " )
fallback [ " original_message_count " ] = int ( context . get ( " message_count " ) or 0 )
fallback [ " original_char_count " ] = char_count
fallback [ " max_chars " ] = max_chars
return fallback
return _budget_compacted_prefill_context ( context , max_chars = max_chars , char_count = char_count )
2026-05-25 00:20:28 +00:00
2026-05-25 00:05:20 +00:00
def _prefill_not_configured ( ) - > dict :
return { " status " : " not_configured " , " source " : " none " , " label " : " " , " messages " : [ ] , " message_count " : 0 }
def _load_prefill_messages_file ( file_raw : str , * , source : str = " file " , status : str = " loaded " ) - > dict :
path = _resolve_prefill_path ( file_raw )
label = path . name or " prefill file "
if not path . exists ( ) :
return { " status " : " error " , " source " : source , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : " prefill file not found " }
try :
messages = _valid_prefill_messages ( json . loads ( path . read_text ( encoding = " utf-8 " ) ) )
return { " status " : status , " source " : source , " label " : label , " messages " : messages , " message_count " : len ( messages ) }
except Exception as exc :
return { " status " : " error " , " source " : source , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : _redact_prefill_status_text ( str ( exc ) ) }
def _prefill_script_timeout ( config_data : dict ) - > float :
raw = os . getenv ( " HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT " , " " ) or str ( config_data . get ( " webui_prefill_messages_script_timeout " ) or " " )
try :
return max ( 0.1 , min ( float ( raw or 5 ) , 30.0 ) )
except Exception :
return 5.0
def _prefill_script_command ( raw ) - > list [ str ] :
if isinstance ( raw , ( list , tuple ) ) :
return [ str ( part ) for part in raw if str ( part ) ]
parts = shlex . split ( str ( raw or " " ) )
if not parts :
return [ ]
# A single script path mirrors prefill_messages_file path resolution. More
# complex commands keep their argv untouched so admins can pass arguments.
if len ( parts ) == 1 :
parts [ 0 ] = str ( _resolve_prefill_path ( parts [ 0 ] ) )
return parts
def _messages_from_prefill_script_output ( text : str ) - > list [ dict ] :
stripped = str ( text or " " ) . strip ( )
if not stripped :
return [ ]
try :
payload = json . loads ( stripped )
except Exception :
payload = None
if isinstance ( payload , dict ) :
payload = payload . get ( " messages " )
messages = _valid_prefill_messages ( payload )
if messages :
return messages
2026-05-27 07:09:01 +00:00
return [ { " role " : " user " , " content " : stripped } ]
2026-05-25 00:05:20 +00:00
def _load_prefill_messages_script ( config_data : dict ) - > dict :
script_raw = os . getenv ( " HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT " , " " ) or config_data . get ( " webui_prefill_messages_script " )
if not script_raw :
return _prefill_not_configured ( )
command = _prefill_script_command ( script_raw )
label = Path ( command [ 0 ] ) . name if command else " prefill script "
if not command :
return { " status " : " error " , " source " : " script " , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : " prefill script is empty " }
try :
proc = subprocess . run (
command ,
text = True ,
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
timeout = _prefill_script_timeout ( config_data ) ,
check = False ,
)
except subprocess . TimeoutExpired :
return { " status " : " error " , " source " : " script " , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : " prefill script timed out " }
except Exception as exc :
return { " status " : " error " , " source " : " script " , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : _redact_prefill_status_text ( str ( exc ) ) }
if proc . returncode != 0 :
err = _redact_prefill_status_text ( proc . stderr or proc . stdout or f " prefill script exited { proc . returncode } " )
return { " status " : " error " , " source " : " script " , " label " : label , " messages " : [ ] , " message_count " : 0 , " error " : err }
2026-05-25 00:20:28 +00:00
if len ( proc . stdout . encode ( " utf-8 " ) ) > _PREFILL_SCRIPT_OUTPUT_LIMIT :
return {
" status " : " error " ,
" source " : " script " ,
" label " : label ,
" messages " : [ ] ,
" message_count " : 0 ,
" error " : f " prefill script output exceeded { _PREFILL_SCRIPT_OUTPUT_LIMIT } bytes " ,
}
2026-05-25 00:05:20 +00:00
messages = _messages_from_prefill_script_output ( proc . stdout )
return { " status " : " loaded " , " source " : " script " , " label " : label , " messages " : messages , " message_count " : len ( messages ) }
2026-05-18 13:22:00 +00:00
def _load_webui_prefill_context (
config_data : Optional [ dict ] = None ,
) - > dict :
""" Load configured WebUI session prefill messages.
2026-05-25 00:05:20 +00:00
Supports the same bounded JSON - file shape used by Hermes Agent . WebUI also
supports its own explicitly opt - in script hook so admins can bridge Joplin ,
Obsidian , Notion , llm - wiki , or another local notes source into ephemeral
turn context without baking any one note provider into the WebUI .
2026-05-18 13:22:00 +00:00
"""
cfg = config_data if isinstance ( config_data , dict ) else get_config ( )
2026-05-25 00:05:20 +00:00
script_context = _load_prefill_messages_script ( cfg )
2026-05-18 13:22:00 +00:00
file_raw = os . getenv ( " HERMES_PREFILL_MESSAGES_FILE " , " " ) or str ( cfg . get ( " prefill_messages_file " ) or " " )
2026-05-28 18:22:13 +00:00
if script_context . get ( " status " ) == " not_configured " :
if file_raw :
2026-05-28 19:51:29 +00:00
return _apply_prefill_context_budget ( _load_prefill_messages_file ( file_raw ) , cfg )
2026-05-28 18:22:13 +00:00
return _prefill_not_configured ( )
if script_context . get ( " status " ) == " error " and file_raw :
file_context = _load_prefill_messages_file ( file_raw , source = " file_fallback " )
if file_context . get ( " status " ) == " loaded " :
file_context [ " script_error " ] = script_context . get ( " error " , " " )
2026-05-28 19:51:29 +00:00
return _apply_prefill_context_budget ( file_context , cfg )
return _apply_prefill_context_budget ( script_context , cfg )
2026-05-18 13:22:00 +00:00
def _public_prefill_context_status ( prefill_context : dict ) - > dict :
""" Strip message bodies before sending context status to the browser. """
return {
" status " : prefill_context . get ( " status " , " not_configured " ) ,
" source " : prefill_context . get ( " source " , " none " ) ,
" label " : prefill_context . get ( " label " , " " ) ,
" message_count " : int ( prefill_context . get ( " message_count " ) or 0 ) ,
* * ( { " error " : prefill_context . get ( " error " , " " ) } if prefill_context . get ( " error " ) else { } ) ,
2026-05-28 19:26:35 +00:00
* * ( { " compacted " : True } if prefill_context . get ( " compacted " ) else { } ) ,
* * ( { " original_source " : prefill_context . get ( " original_source " , " " ) } if prefill_context . get ( " original_source " ) else { } ) ,
* * ( { " original_message_count " : int ( prefill_context . get ( " original_message_count " ) or 0 ) } if prefill_context . get ( " original_message_count " ) else { } ) ,
* * ( { " original_char_count " : int ( prefill_context . get ( " original_char_count " ) or 0 ) } if prefill_context . get ( " original_char_count " ) else { } ) ,
* * ( { " max_chars " : int ( prefill_context . get ( " max_chars " ) or 0 ) } if prefill_context . get ( " max_chars " ) else { } ) ,
2026-05-18 13:22:00 +00:00
}
2026-06-02 19:36:25 +00:00
def _webui_delivery_context_prompt ( config_data : Optional [ dict ] = None ) - > str :
""" Return platform/delivery context for the ephemeral system prompt.
Connected platforms , home channels , and scheduled - task delivery hints
are injected into the system prompt ( safe for role alternation ) rather
than as a prefill ` ` user ` ` message , which strict chat templates ( Mistral ,
Gemma ) reject .
2026-05-28 18:22:13 +00:00
2026-06-02 19:36:25 +00:00
NOTE : This function only covers platform / delivery info . The session
framing ( \" Source: WebUI \" , \" Session ID \" , \" Profile \" , \" Workspace \" ) is
emitted by ` ` _webui_surface_context_prompt ( ) ` ` , which is called from
` ` _webui_ephemeral_system_prompt ( ) ` ` before this helper . If you
refactor this area , keep that surface call in place — the two helpers
together produce the full session context block .
2026-05-28 18:22:13 +00:00
"""
cfg = config_data if isinstance ( config_data , dict ) else get_config ( )
2026-06-02 19:36:25 +00:00
lines : list [ str ] = [ ]
2026-05-28 18:22:13 +00:00
2026-06-02 19:36:25 +00:00
display_hermes_home = None
2026-05-28 18:22:13 +00:00
try :
2026-06-02 19:36:25 +00:00
from hermes_constants import get_hermes_home , display_hermes_home as _dh
display_hermes_home = _dh
2026-05-28 18:22:13 +00:00
except Exception :
2026-06-02 19:36:25 +00:00
get_hermes_home = None # type: ignore[assignment]
2026-05-28 18:22:13 +00:00
connected = [ " local (files on this machine) " ]
try :
2026-06-02 19:36:25 +00:00
if get_hermes_home is not None :
state_path = get_hermes_home ( ) / " gateway_state.json "
if state_path . exists ( ) :
raw_state = json . loads ( state_path . read_text ( encoding = " utf-8 " ) )
platforms = raw_state . get ( " platforms " ) if isinstance ( raw_state , dict ) else { }
if isinstance ( platforms , dict ) :
for name in sorted ( platforms ) :
pdata = platforms . get ( name ) or { }
if isinstance ( pdata , dict ) and pdata . get ( " state " ) == " connected " and name != " local " :
connected . append ( f " { name } : Connected ✓ " )
2026-05-28 18:22:13 +00:00
except Exception :
2026-06-02 19:36:25 +00:00
pass
2026-05-28 18:22:13 +00:00
lines . append ( f " **Connected Platforms:** { ' , ' . join ( connected ) } " )
home_channels = { }
try :
platforms_cfg = cfg . get ( " platforms " , { } ) if isinstance ( cfg , dict ) else { }
if isinstance ( platforms_cfg , dict ) :
for name , pdata in platforms_cfg . items ( ) :
if not isinstance ( pdata , dict ) :
continue
if pdata . get ( " enabled " ) is False :
continue
home = pdata . get ( " home_channel " )
if isinstance ( home , dict ) :
home_channels [ str ( name ) ] = str ( home . get ( " name " ) or name )
except Exception :
home_channels = { }
if home_channels :
lines . append ( " " )
lines . append ( " **Home Channels (default destinations):** " )
for platform , label in sorted ( home_channels . items ( ) ) :
lines . append ( f " - { platform } : { label } " )
lines . append ( " " )
lines . append ( " **Delivery options for scheduled tasks:** " )
lines . append ( " - ` \" origin \" ` → Back to this WebUI/browser session when the WebUI runtime supports origin delivery; otherwise prefer an explicit platform target. " )
try :
2026-06-02 19:36:25 +00:00
home_display = display_hermes_home ( ) if display_hermes_home else " ~/.hermes "
2026-05-28 18:22:13 +00:00
except Exception :
home_display = " ~/.hermes "
lines . append ( f " - ` \" local \" ` → Save to local files only ( { home_display } /cron/output/) " )
for platform , label in sorted ( home_channels . items ( ) ) :
lines . append ( f " - ` \" { platform } \" ` → Home channel ( { label } ) " )
lines . append ( " " )
lines . append ( " *For explicit targeting, use ` \" platform:chat_id \" ` format if the user provides a specific chat ID. Do not invent private IDs.* " )
2026-06-02 19:36:25 +00:00
return " \n " . join ( lines )
2026-05-28 18:22:13 +00:00
def _prefill_messages_with_webui_context ( prefill_context : dict , config_data : Optional [ dict ] = None ) - > list [ dict ] :
2026-06-02 19:36:25 +00:00
""" Combine recall prefill with WebUI session context.
The session context ( connected platforms , delivery hints ) is injected
via ` ` _webui_ephemeral_system_prompt ` ` / ` ` ephemeral_system_prompt ` `
instead of as a prefill ` ` user ` ` message . Adding it as a user message
creates two consecutive user turns ( prefill + actual ) which strict chat
templates ( Mistral , Gemma ) reject with a Jinja 500.
"""
return list ( prefill_context . get ( " messages " ) or [ ] )
2026-05-28 18:22:13 +00:00
Release v0.51.262 — Release ID (stage-r12) (#3617)
## Release v0.51.262 — Release ID (stage-r12)
Phase-3 light slice (no-screenshot items) — 3 PRs.
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3432 (#3532) | @franksong2702 | Normalize the recall-prefill terminal `user` turn so WebUI doesn't send adjacent `user` roles to strict chat templates (Mistral/Gemma/Jinja). `_normalize_prefill_messages_before_user_turn()` in both `streaming.py` + `gateway_chat.py`; drops only the terminal user tail, preserves assistant/system/mid-list context. **Rebased onto master** (was CONFLICTING). |
| #2558 (#3516) | @rodboev | "Reveal in file manager" now translates container workspace paths (`/workspace`) back to the host mount path for Docker deployments (traversal-safe via `safe_resolve` + sibling-prefix guard). |
### Changed
| Issue/PR | Author | Change |
|----------|--------|--------|
| (#3539) | @Lyr-GW | Completed the Chinese (Simplified) `zh` localization (MCP controls, tool-list pagination) with all interpolations preserved, and the language dropdown now applies the locale **instantly** on change. |
### Review fix absorbed (Codex)
#3539 also added an `allowed=['en','zh']` filter to the Settings language dropdown, which dropped the 9 other shipped locales (it/ja/ru/es/de/pt/ko/fr/tr) — and since save falls back to `en` when the select has no matching option, an existing user of those locales would be **silently reset to English** on a Settings save. Removed the filter (dropdown enumerates all `LOCALES` again, matching master); partially-translated locales fall back per-key to English at render. + regression test `test_issue3539_language_dropdown_all_locales.py`.
### Gate
- Full pytest suite: **7701 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (dropdown drops-locales) → fixed → **SAFE TO SHIP** (verified prefill drops only terminal user tail in both paths, Docker path-translation traversal-safe, zh interpolations preserved)
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Lyr-GW <Lyr-GW@users.noreply.github.com>
2026-06-05 00:16:31 +00:00
def _normalize_prefill_messages_before_user_turn ( prefill_messages : list [ dict ] ) - > list [ dict ] :
""" Ensure WebUI prefill does not end with user role before an appended turn.
Some upstream prefill sources can end with ` role : user ` ( for example ,
session context or recall snippets ) . WebUI always appends the current user
turn after prefill in the streaming path , so a terminal user role creates an
adjacent user / user sequence that strict chat templates ( Gemma , Mistral / Jinja )
reject .
To keep behavior scoped , only consecutive terminal user messages are removed
just before that boundary ; earlier roles remain untouched .
"""
sanitized = list ( prefill_messages or [ ] )
n_dropped = 0
while sanitized :
last_message = sanitized [ - 1 ]
if not isinstance ( last_message , dict ) :
break
if str ( last_message . get ( " role " ) or " " ) . strip ( ) . lower ( ) != " user " :
break
sanitized . pop ( )
n_dropped + = 1
if n_dropped :
logger . debug ( " Dropped %d trailing user message(s) from prefill " , n_dropped )
return sanitized
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
def _has_new_assistant_reply ( all_messages : list , prev_count : int ) - > bool :
""" Return True if *new* messages (beyond ``prev_count``) contain an
assistant message with non - empty content .
` ` all_messages ` ` is ` ` result . get ( ' messages ' ) ` ` which includes the full
conversation history . ` ` prev_count ` ` is ` ` len ( _previous_context_messages ) ` `
— the number of messages present before the current turn started . Only
messages at index > = prev_count are inspected so that historical assistant
replies don ' t mask a silent failure on the current turn.
2026-05-15 10:04:53 +00:00
If ` ` len ( all_messages ) < prev_count ` ` ( an edge - case shrink ) , there is no
reliable new - message slice to inspect . Treat that as " no new assistant
reply " so stale historical assistant replies cannot mask a silent failure.
When ` ` len == prev_count ` ` , there are no new messages and we return False .
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
"""
if len ( all_messages ) > prev_count :
2026-05-14 06:48:08 +00:00
# Normal case: new messages appended beyond the pre-turn history.
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
candidates = all_messages [ prev_count : ]
elif len ( all_messages ) < prev_count :
2026-05-15 10:04:53 +00:00
return False
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
else :
2026-05-14 06:48:08 +00:00
# Same length. In production this means no new messages were appended.
# However, some test fixtures replace the entire message list rather
# than appending, so check whether the tail changed.
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
return False
return any (
m . get ( ' role ' ) == ' assistant ' and str ( m . get ( ' content ' ) or ' ' ) . strip ( )
for m in candidates
)
2026-05-15 20:00:30 +00:00
def _preferred_agent_display_name ( ) - > str :
""" Return the configured assistant display name for user-facing copy. """
try :
name = str ( ( load_settings ( ) or { } ) . get ( ' bot_name ' ) or ' ' ) . strip ( )
except Exception :
logger . debug ( " Failed to load bot_name for cancellation copy " , exc_info = True )
name = ' '
return name or ' Hermes '
2026-05-19 13:06:19 +00:00
def _preferred_agent_display_name_for_session ( session ) - > str :
profile = str ( getattr ( session , ' profile ' , ' ' ) or ' ' ) . strip ( )
if profile and profile != ' default ' :
return profile [ : 1 ] . upper ( ) + profile [ 1 : ]
return _preferred_agent_display_name ( )
2026-05-15 20:00:30 +00:00
def _cancelled_turn_hint ( agent_name : str | None = None ) - > str :
name = str ( agent_name or _preferred_agent_display_name ( ) ) . strip ( ) or ' Hermes '
return f ' The run was cancelled by the user before { name } finished. No provider failure occurred. '
2026-05-07 01:15:53 +00:00
def _classify_provider_error ( err_str : str , exc = None , * , silent_failure : bool = False ) - > dict :
""" Classify provider/agent failure text for WebUI apperror UX.
Keep this string - based until hermes - agent exposes stable structured
provider error classes for Codex OAuth plan limits .
"""
err_str = str ( err_str or ' ' )
_err_lower = err_str . lower ( )
_exc_name = type ( exc ) . __name__ if exc is not None else ' '
2026-05-12 19:53:28 +00:00
_is_cancelled = (
' cancelled by user ' in _err_lower
or ' canceled by user ' in _err_lower
or ' user cancelled ' in _err_lower
or ' user canceled ' in _err_lower
or ' task cancelled ' in _err_lower
or ' task canceled ' in _err_lower
2026-05-12 22:43:36 +00:00
or ' cancellederror ' in _err_lower
or ( exc is not None and _exc_name in ( ' CancelledError ' , ' CanceledError ' ) )
2026-05-12 19:53:28 +00:00
)
_is_interrupted = (
not _is_cancelled
and (
' interrupted by user ' in _err_lower
or ' response interrupted ' in _err_lower
or ' operation interrupted ' in _err_lower
or ' operation was interrupted ' in _err_lower
or ' operation aborted ' in _err_lower
or ' request was aborted ' in _err_lower
or ' aborterror ' in _err_lower
or ( exc is not None and type ( exc ) . __name__ in ( ' KeyboardInterrupt ' , ' AbortError ' ) )
)
)
if _is_cancelled :
return {
' label ' : ' Task cancelled ' ,
' type ' : ' cancelled ' ,
2026-05-15 20:00:30 +00:00
' hint ' : _cancelled_turn_hint ( ) ,
2026-05-12 19:53:28 +00:00
}
if _is_interrupted :
return {
' label ' : ' Response interrupted ' ,
' type ' : ' interrupted ' ,
' hint ' : ' The run stopped before a provider response completed. If you did not cancel it, try again. ' ,
}
2026-05-07 01:15:53 +00:00
_is_quota = _is_quota_error_text ( err_str )
_is_auth = (
not _is_quota and (
' 401 ' in err_str
or ( exc is not None and ' AuthenticationError ' in _exc_name )
or ' authentication ' in _err_lower
or ' unauthorized ' in _err_lower
or ' invalid api key ' in _err_lower
or ' invalid_api_key ' in _err_lower
or ' no cookie auth credentials ' in _err_lower
)
)
_is_not_found = (
# model_not_found hints mention Settings / `hermes model` below.
' 404 ' in err_str
or ' not found ' in _err_lower
or ' does not exist ' in _err_lower
or ' model not found ' in _err_lower
or ' model_not_found ' in _err_lower # hint below points to Settings / `hermes model`
or ' invalid model ' in _err_lower
or ' does not match any known model ' in _err_lower
or ' unknown model ' in _err_lower
)
_is_rate_limit = ( not _is_quota ) and (
' rate limit ' in _err_lower or ' 429 ' in err_str or ( exc is not None and ' RateLimitError ' in _exc_name )
)
2026-06-06 05:45:09 +00:00
_is_compression_exhausted = (
' compression_exhausted ' in _err_lower
or ' compression exhausted ' in _err_lower
or ( ' context length exceeded ' in _err_lower and ' cannot compress further ' in _err_lower )
or ( ' context compression ' in _err_lower and ' max compression attempts ' in _err_lower )
)
2026-05-07 01:15:53 +00:00
if _is_quota :
return {
' label ' : ' Out of credits ' ,
' type ' : ' quota_exhausted ' ,
' hint ' : ' Your provider account is out of credits or usage. Top up, wait for the plan window to reset, or switch providers via `hermes model`. ' ,
}
if _is_rate_limit :
return {
' label ' : ' Rate limit reached ' ,
' type ' : ' rate_limit ' ,
' hint ' : ' Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment. ' ,
}
if _is_auth :
return {
' label ' : ' Authentication failed ' ,
' type ' : ' auth_mismatch ' ,
' hint ' : ' The selected model may not be supported by your configured provider or your API key is invalid. Run `hermes model` in your terminal to update credentials, then restart the WebUI. ' ,
}
if _is_not_found :
return {
' label ' : ' Model not found ' ,
' type ' : ' model_not_found ' ,
' hint ' : ' The selected model was not found by the provider. Check the model ID in Settings or run `hermes model` to verify it exists for your provider. ' ,
}
2026-06-06 05:45:09 +00:00
if _is_compression_exhausted :
return {
' label ' : ' Context compression exhausted ' ,
' type ' : ' compression_exhausted ' ,
' hint ' : ' The conversation context is too large to compress safely. Start a new conversation or retry with a narrower task. ' ,
}
2026-05-07 01:15:53 +00:00
if silent_failure :
return {
' label ' : ' No response from provider ' ,
# Preserve the existing no_response event type (#373) while making
# the catch-all silent-failure message more specific for #1765.
' type ' : ' no_response ' ,
' hint ' : ' The provider returned no content and no error. This often means a usage/rate limit was hit silently. Check provider status, switch providers via `hermes model`, or try again in a moment. ' ,
}
return { ' label ' : ' Error ' , ' type ' : ' error ' , ' hint ' : ' ' }
def _provider_error_payload ( message : str , err_type : str , hint : str = ' ' ) - > dict :
""" Build a bounded, redacted apperror payload with provider details. """
_message = str ( message or ' ' )
_safe_message = _redact_text ( _message ) . strip ( ) if _message else ' '
payload : dict = { ' message ' : _safe_message or _message , ' type ' : err_type }
if hint :
payload [ ' hint ' ] = hint
if _safe_message :
_details = _safe_message
if len ( _details ) > 1200 :
_details = _details [ : 1197 ] . rstrip ( ) + ' … '
if _details :
payload [ ' details ' ] = _details
return payload
2026-06-08 08:24:34 +00:00
_MAX_ITERATION_SUMMARY_REQUEST = (
" You ' ve reached the maximum number of tool-calling iterations allowed. "
" Please provide a final response summarizing what you ' ve found and accomplished "
" so far, without calling any more tools. "
)
def _is_synthetic_max_iteration_summary_request ( message ) - > bool :
""" Return True for Hermes Agent ' s internal max-iteration summary prompt. """
if not isinstance ( message , dict ) or message . get ( ' role ' ) != ' user ' :
return False
text = " " . join ( _message_text ( message . get ( ' content ' , ' ' ) ) . split ( ) )
expected = " " . join ( _MAX_ITERATION_SUMMARY_REQUEST . split ( ) )
return text == expected
def _drop_synthetic_max_iteration_summary_requests ( messages , * , enabled : bool = True ) :
""" Remove Agent-internal max-iteration summary prompts from WebUI state. """
if not enabled :
return list ( messages or [ ] )
return [
msg
for msg in list ( messages or [ ] )
if not _is_synthetic_max_iteration_summary_request ( msg )
]
def _agent_result_tool_limit_reached ( result ) - > bool :
2026-06-11 07:31:34 +00:00
""" Return True when current-turn metadata says the tool iteration cap fired. """
2026-06-08 08:24:34 +00:00
if not isinstance ( result , dict ) :
return False
fields = [
result . get ( ' turn_exit_reason ' ) ,
result . get ( ' terminal_reason ' ) ,
result . get ( ' status ' ) ,
result . get ( ' state ' ) ,
result . get ( ' error ' ) ,
]
haystack = " " . join ( str ( value or ' ' ) for value in fields ) . lower ( )
if (
' max_iterations_reached ' in haystack
or ' maximum number of tool-calling iterations ' in haystack
2026-06-11 09:58:15 +00:00
or ( ' tool-calling iterations ' in haystack and ' maximum ' in haystack )
2026-06-08 08:24:34 +00:00
) :
return True
2026-06-11 07:31:34 +00:00
return False
2026-06-08 08:24:34 +00:00
def _mark_latest_assistant_tool_limit_status ( messages ) - > bool :
""" Annotate the latest usable assistant final answer as limit-stopped. """
for msg in reversed ( list ( messages or [ ] ) ) :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' _error ' ) or msg . get ( ' role ' ) != ' assistant ' :
continue
content = msg . get ( ' content ' )
if isinstance ( content , list ) :
text = ' \n ' . join (
str ( part . get ( ' text ' ) or part . get ( ' content ' ) or ' ' )
for part in content
if isinstance ( part , dict )
)
else :
text = str ( content or ' ' )
if msg . get ( ' tool_calls ' ) or not text . strip ( ) :
continue
msg [ ' _terminal_state ' ] = ' tool_limit_reached '
msg [ ' _terminal_reason ' ] = ' max_iterations '
msg . setdefault ( ' _statusCard ' , {
' title ' : ' Tool iteration limit reached ' ,
' subtitle ' : ' Stopped because the tool iteration limit was reached. ' ,
' rows ' : [
{ ' label ' : ' State ' , ' value ' : ' Limit reached ' } ,
{ ' label ' : ' Next step ' , ' value ' : ' Start a new turn to continue. ' } ,
] ,
} )
return True
return False
2026-05-12 19:53:28 +00:00
def _session_has_cancel_marker ( session ) - > bool :
""" Return True if a visible cancel/interrupted marker is already persisted. """
for msg in reversed ( getattr ( session , ' messages ' , None ) or [ ] ) :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' role ' ) == ' user ' :
return False
if msg . get ( ' role ' ) != ' assistant ' :
continue
content = msg . get ( ' content ' )
text = ' '
if isinstance ( content , str ) :
text = content
elif isinstance ( content , list ) :
parts = [ ]
for part in content :
if isinstance ( part , dict ) :
parts . append ( str ( part . get ( ' text ' ) or part . get ( ' content ' ) or ' ' ) )
text = ' \n ' . join ( parts )
normalized = text . strip ( ) . lower ( )
2026-05-12 22:43:36 +00:00
if any ( pattern in normalized for pattern in _CANCEL_MARKER_PATTERNS ) :
2026-05-12 19:53:28 +00:00
return True
return False
2026-05-19 13:06:19 +00:00
def _cancelled_turn_content ( message : str = ' Task cancelled. ' , agent_name : str | None = None ) - > str :
2026-05-12 19:53:28 +00:00
""" Return cancelled-turn copy matching the verbose provider-error layout. """
_message = str ( message or ' Task cancelled. ' ) . strip ( )
if not _message . endswith ( ' . ' ) :
_message + = ' . '
return (
f " **Task cancelled:** { _message } \n \n "
2026-05-19 13:06:19 +00:00
f " * { _cancelled_turn_hint ( agent_name ) } * "
2026-05-12 19:53:28 +00:00
)
def _persist_cancelled_turn ( session , * , message : str = ' Task cancelled. ' ) - > None :
""" Persist a user-cancelled terminal state without provider-error wording.
cancel_stream ( ) usually writes this marker first , but the streaming thread can
later unwind through the silent - failure or exception path . Those paths must
not append a misleading provider no - response error after an explicit cancel .
"""
_materialize_pending_user_turn_before_error ( session )
session . active_stream_id = None
session . pending_user_message = None
session . pending_attachments = [ ]
session . pending_started_at = None
2026-06-18 22:21:10 +00:00
session . pending_user_source = None
2026-05-12 19:53:28 +00:00
if not _session_has_cancel_marker ( session ) :
2026-05-19 13:06:19 +00:00
agent_name = _preferred_agent_display_name_for_session ( session )
2026-05-12 19:53:28 +00:00
session . messages . append ( {
' role ' : ' assistant ' ,
2026-05-19 13:06:19 +00:00
' content ' : _cancelled_turn_content ( message , agent_name ) ,
2026-05-12 19:53:28 +00:00
' _error ' : True ,
' provider_details ' : str ( message or ' Task cancelled. ' ) . strip ( ) ,
' provider_details_label ' : ' Cancellation details ' ,
' timestamp ' : int ( time . time ( ) ) ,
} )
def _cleanup_ephemeral_cancelled_turn ( session ) - > None :
""" Remove transient /btw session state after a cancel without saving it. """
session . active_stream_id = None
session . pending_user_message = None
session . pending_attachments = [ ]
session . pending_started_at = None
2026-06-18 22:21:10 +00:00
session . pending_user_source = None
2026-05-12 19:53:28 +00:00
try :
import pathlib
pathlib . Path ( session . path ) . unlink ( missing_ok = True )
except Exception :
logger . debug ( " Failed to clean up ephemeral cancelled session " , exc_info = True )
def _finalize_cancelled_turn ( session , * , ephemeral : bool = False , message : str = ' Task cancelled. ' ) - > None :
""" Finalize a cancelled turn for persistent or ephemeral sessions. """
if ephemeral :
_cleanup_ephemeral_cancelled_turn ( session )
return
_persist_cancelled_turn ( session , message = message )
try :
session . save ( )
except Exception :
logger . debug ( " Failed to persist cancelled turn " , exc_info = True )
docs(troubleshooting): bake the #1695 diagnostic flow into the error message + a new troubleshooting doc
Closes #1695.
@Patrick-81 reported the bare "AIAgent not available -- check that
hermes-agent is on sys.path" error on a symlinked install (~/Programmes/hermes-agent
linked to ~/hermes-agent). The maintainer's response — three diagnostic
commands plus `pip install -e .` in the agent dir — fixed it for them.
This PR captures both halves of that learning so the next user with the
same shape doesn't have to file a new issue:
1. **Error message diagnostic block.** New helper
`_aiagent_import_error_detail()` in api/streaming.py builds a multi-line
diagnostic when the import fails, including:
- the running Python interpreter
- HERMES_WEBUI_AGENT_DIR (set value, or "(not set)")
- sys.path entries that mention hermes/agent (or "no entries mention..."
— itself a strong diagnostic signal)
- the most-common fix (`pip install -e .` in the agent dir)
- a pointer to docs/troubleshooting.md
The original error message string is preserved as the FIRST line so
existing log scrapers and docs-search keep matching.
Helper is kept as a separate function so it stays out of the hot path
until we actually need to raise — building it on every successful import
would be wasted work.
2. **New docs/troubleshooting.md.** Symptom → Why → Diagnostic commands →
Fix → When-to-file-a-bug template, with one entry to start: the
"AIAgent not available" flow Patrick-81 walked through. Future
recurring failure modes follow the same template. Required a one-line
addition to .gitignore — docs/* is gitignored with an allowlist, and
the new file needed `!docs/troubleshooting.md` to be tracked.
3. **README link.** docs/troubleshooting.md added to the `## Docs` section
so users know where to look first.
13 regression tests in tests/test_1695_aiagent_import_error_detail.py:
9 for the helper output shape (preserves original message line, includes
running python, shows HERMES_WEBUI_AGENT_DIR set/unset both ways, includes
pip-install-e hint, points at troubleshooting doc, lists relevant sys.path
entries when present, says "no entries..." when absent, output is multi-line)
plus 4 for the docs-presence regression (file exists, has the AIAgent
section, includes pip install -e ., describes the diagnostic chain with
readlink + agent/__init__.py verification).
190 streaming/aiagent tests pass after the change. ast.parse on
api/streaming.py clean.
CI failure on prior push was due to the docs/* gitignore swallowing the
new troubleshooting.md file silently — this commit adds the allowlist
entry so the file is tracked.
2026-05-05 22:01:23 +00:00
def _aiagent_import_error_detail ( ) - > str :
""" Return a multi-line diagnostic string for the " AIAgent not available " path.
The bare ImportError ( " AIAgent not available -- check that hermes-agent is
on sys . path " ) leaves users guessing at which python is running, where it ' s
looking , and what to fix . We assemble the same evidence a maintainer would
ask for first ( issue #1695): the python that's running, the agent_dir env
var if set , the sys . path entries that mention ' hermes ' , and the most - common
fix ( ` pip install - e . ` in the agent dir ) .
Kept as a separate helper so it stays out of the hot path until we actually
need to raise — building it on every successful import would be wasted work .
"""
import os as _os
import sys as _sys
lines = [ " AIAgent not available -- check that hermes-agent is on sys.path " ]
lines . append ( " " )
lines . append ( f " python: { _sys . executable } " )
agent_dir = _os . environ . get ( " HERMES_WEBUI_AGENT_DIR " )
if agent_dir :
lines . append ( f " HERMES_WEBUI_AGENT_DIR: { agent_dir } " )
else :
lines . append ( " HERMES_WEBUI_AGENT_DIR: (not set) " )
# Show only the sys.path entries that look relevant — full sys.path is noisy.
relevant = [ p for p in _sys . path if " hermes " in p . lower ( ) or " agent " in p . lower ( ) ]
if relevant :
lines . append ( " sys.path entries mentioning hermes/agent: " )
for entry in relevant [ : 6 ] :
lines . append ( f " - { entry } " )
if len ( relevant ) > 6 :
lines . append ( f " ... and { len ( relevant ) - 6 } more " )
else :
lines . append ( " sys.path: (no entries mention hermes or agent) " )
lines . append ( " " )
lines . append ( " Most common fix: install the agent in editable mode so its modules " )
lines . append ( " appear on sys.path: " )
lines . append ( " " )
lines . append ( " cd /path/to/hermes-agent " )
lines . append ( " pip install -e . " )
lines . append ( " " )
lines . append ( " Then restart the WebUI. " )
lines . append ( " " )
lines . append ( ' Full troubleshooting: docs/troubleshooting.md ( " AIAgent not available " ) ' )
return " \n " . join ( lines )
2026-03-31 03:40:19 +00:00
from api . models import get_session , title_from
from api . workspace import set_last_workspace
2026-04-04 05:13:12 +00:00
# Fields that are safe to send to LLM provider APIs.
# Everything else (attachments, timestamp, _ts, etc.) is display-only
# metadata added by the webui and must be stripped before the API call.
2026-05-13 18:04:59 +00:00
_API_SAFE_MSG_KEYS = { ' role ' , ' content ' , ' tool_calls ' , ' tool_call_id ' , ' name ' , ' refusal ' , ' reasoning_content ' }
2026-04-04 05:13:12 +00:00
2026-04-28 15:18:51 +00:00
_NATIVE_IMAGE_MAX_BYTES = 20 * 1024 * 1024
2026-05-05 00:08:23 +00:00
_GATEWAY_ROUTING_TOP_LEVEL_KEYS = {
' used_provider ' ,
' used_model ' ,
' requested_provider ' ,
' requested_model ' ,
}
_GATEWAY_ROUTING_CONTAINER_KEYS = (
' llm_gateway ' ,
' gateway ' ,
' metadata ' ,
' response_metadata ' ,
' routing_metadata ' ,
' usage ' ,
)
_GATEWAY_ROUTING_ATTEMPT_KEYS = {
' provider ' , ' model ' , ' status ' , ' reason ' , ' selection_reason ' , ' score ' ,
' latency_ms ' , ' error ' , ' timestamp ' , ' selected ' , ' attempt ' , ' attempt_index ' ,
}
def _clean_gateway_routing_scalar ( value ) :
if value is None :
return None
if isinstance ( value , ( str , int , float , bool ) ) :
text = str ( value ) . strip ( )
if not text :
return None
return value if isinstance ( value , ( int , float , bool ) ) else text [ : 240 ]
return None
def _find_gateway_metadata_payload ( payload ) :
if not isinstance ( payload , dict ) :
return None
if any ( k in payload for k in _GATEWAY_ROUTING_TOP_LEVEL_KEYS ) or isinstance ( payload . get ( ' routing ' ) , list ) :
return payload
for key in _GATEWAY_ROUTING_CONTAINER_KEYS :
nested = payload . get ( key )
found = _find_gateway_metadata_payload ( nested )
if found :
return found
return None
def _normalize_gateway_routing_metadata ( payload , requested_model = None , requested_provider = None ) :
""" Return safe LLM Gateway routing metadata, or None when absent.
LLM Gateway response metadata can contain provider / model routing details ,
but WebUI must only persist display - safe scalars and a bounded routing list .
Secrets or provider - specific request objects are deliberately ignored .
"""
src = _find_gateway_metadata_payload ( payload )
if not src :
return None
normalized = { }
for key in _GATEWAY_ROUTING_TOP_LEVEL_KEYS :
value = _clean_gateway_routing_scalar ( src . get ( key ) )
if value is not None :
normalized [ key ] = value
if ' requested_model ' not in normalized :
fallback_model = _clean_gateway_routing_scalar ( requested_model )
if fallback_model is not None :
normalized [ ' requested_model ' ] = fallback_model
if ' requested_provider ' not in normalized :
fallback_provider = _clean_gateway_routing_scalar ( requested_provider )
if fallback_provider is not None :
normalized [ ' requested_provider ' ] = fallback_provider
routing = [ ]
raw_routing = src . get ( ' routing ' )
if isinstance ( raw_routing , list ) :
for attempt in raw_routing [ : 12 ] :
if not isinstance ( attempt , dict ) :
continue
clean_attempt = { }
for key in _GATEWAY_ROUTING_ATTEMPT_KEYS :
value = _clean_gateway_routing_scalar ( attempt . get ( key ) )
if value is not None :
clean_attempt [ key ] = value
if clean_attempt :
routing . append ( clean_attempt )
if routing :
normalized [ ' routing ' ] = routing
used_provider = str ( normalized . get ( ' used_provider ' ) or ' ' ) . strip ( ) . lower ( )
requested_provider_norm = str ( normalized . get ( ' requested_provider ' ) or ' ' ) . strip ( ) . lower ( )
used_model = str ( normalized . get ( ' used_model ' ) or ' ' ) . strip ( ) . lower ( )
requested_model_norm = str ( normalized . get ( ' requested_model ' ) or ' ' ) . strip ( ) . lower ( )
provider_changed = bool ( used_provider and requested_provider_norm and used_provider != requested_provider_norm )
model_changed = bool ( used_model and requested_model_norm and used_model != requested_model_norm )
attempted_providers = [
str ( a . get ( ' provider ' ) or ' ' ) . strip ( ) . lower ( )
for a in routing
if a . get ( ' provider ' )
]
distinct_attempted_providers = { p for p in attempted_providers if p }
failed_before_selection = any (
str ( a . get ( ' status ' ) or ' ' ) . strip ( ) . lower ( ) in { ' failed ' , ' error ' , ' timeout ' , ' rejected ' }
for a in routing
)
has_failover = bool ( provider_changed or len ( distinct_attempted_providers ) > 1 or failed_before_selection )
if not (
normalized . get ( ' used_provider ' ) or normalized . get ( ' used_model ' ) or routing or provider_changed or model_changed
) :
return None
normalized [ ' provider_changed ' ] = provider_changed
normalized [ ' model_changed ' ] = model_changed
normalized [ ' has_failover ' ] = has_failover
return normalized
def _extract_gateway_routing_metadata ( agent , result , requested_model = None , requested_provider = None ) :
candidates = [ ]
if isinstance ( result , dict ) :
candidates . extend ( [
result . get ( ' llm_gateway ' ) ,
result . get ( ' gateway ' ) ,
result . get ( ' metadata ' ) ,
result . get ( ' response_metadata ' ) ,
result . get ( ' routing_metadata ' ) ,
result . get ( ' usage ' ) ,
result ,
] )
for attr in (
' llm_gateway_metadata ' ,
' gateway_metadata ' ,
' last_response_metadata ' ,
' response_metadata ' ,
' routing_metadata ' ,
' last_usage ' ,
) :
if agent is not None :
candidates . append ( getattr ( agent , attr , None ) )
for candidate in candidates :
normalized = _normalize_gateway_routing_metadata (
candidate ,
requested_model = requested_model ,
requested_provider = requested_provider ,
)
if normalized :
return normalized
return None
2026-04-28 15:18:51 +00:00
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
def _build_agent_thread_env ( profile_runtime_env : dict | None , workspace : str , session_id : str , profile_home : str ) - > dict :
""" Build thread-local agent env with per-run values overriding profile defaults.
Profile runtime env may include TERMINAL_CWD from config . yaml . Passing it as
* * kwargs alongside an explicit TERMINAL_CWD raises TypeError before the
agent starts , so merge into one dict first and let the active workspace win .
"""
env = dict ( profile_runtime_env or { } )
env . update ( {
' TERMINAL_CWD ' : str ( workspace ) ,
' HERMES_EXEC_ASK ' : ' 1 ' ,
' HERMES_SESSION_KEY ' : session_id ,
2026-05-15 00:22:44 +00:00
' HERMES_SESSION_ID ' : session_id ,
' HERMES_SESSION_PLATFORM ' : ' webui ' ,
2026-06-09 05:36:18 +00:00
# process_complete agent-wakeup wiring (ours-original, Option B): the
# terminal_tool watcher routing gate (terminal_tool.py:~1940) reads
# HERMES_SESSION_CHAT_ID to populate pending_watchers for WebUI
# sessions so notify_on_complete completions enqueue and the agent
# can be woken. HERMES_SESSION_ID/PLATFORM come from upstream #2279.
' HERMES_SESSION_CHAT_ID ' : str ( session_id ) ,
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
' HERMES_HOME ' : profile_home ,
} )
return env
2026-06-09 05:36:18 +00:00
# ── Per-turn session identity (xsession wakeup misroute root fix — Option 1) ─
# WebUI bound per-turn session identity ONLY to the process-global
# os.environ['HERMES_SESSION_KEY'] (turn-start, line ~3263) and released the
# env lock BEFORE the agent ran. WebUI never called any contextvar setter, so
# gateway.session_context._SESSION_KEY stayed _UNSET and
# tools.approval.get_current_session_key (the EXACT call a
# notify_on_complete background spawn makes in terminal_tool.py:~1928) fell
# back to that racy process-global slot. Two concurrent WebUI turns therefore
# raced on one slot: session A's spawn could capture session B's id, and at
# completion the server-side wakeup turn started for the WRONG session
# (RCA t_f62ff1e8, agent.log:6632). The agent worker runs synchronously inside
# the _run_agent_streaming thread (concurrent tool batches use
# contextvars.copy_context() so children inherit this binding); binding the
# context-local here makes the capture task/thread-local and race-immune.
def _set_turn_session_identity ( session_id : str ) :
""" Bind THIS turn ' s session identity to the current (task/thread-local)
context and return an opaque token for _reset_turn_session_identity .
Binds two context - locals so every session - key consumer is covered without
a race :
* ` ` tools . approval . _approval_session_key ` ` — checked FIRST by
` ` get_current_session_key ` ` ( the exact call terminal_tool . py makes for
a notify_on_complete background spawn : the bug path ) .
* ` ` gateway . session_context . _SESSION_KEY ` ` — read by direct
` ` get_session_env ( " HERMES_SESSION_KEY " ) ` ` consumers ( e . g . the sudo
password cache scope , terminal_tool . py : 272 ) .
It deliberately does NOT call ` ` gateway . session_context . set_session_vars ` ` :
that blanket setter also zeroes the platform / chat_id / user contextvars ,
flipping ` ` HERMES_SESSION_PLATFORM ` ` from its env fallback ( ` ` ' webui ' ` ` ,
still written to os . environ at turn - start ) to an explicit ` ` " " ` ` — which
would break the ` ` notify_on_complete ` ` watcher registration gate in
terminal_tool . py : ~ 1966. Only the session - key identity is bound ; every
other session var keeps its existing os . environ fallback ( CLI / cron compat
preserved — when these contextvars are _UNSET , get_session_env still falls
back to os . environ ) .
"""
sid = str ( session_id or " " )
tokens : dict = { }
try :
from tools . approval import set_current_session_key
tokens [ " approval " ] = set_current_session_key ( sid )
except Exception :
logger . debug ( " per-turn approval session-key bind failed " , exc_info = True )
try :
from gateway . session_context import _SESSION_KEY as _SK
tokens [ " session_key " ] = _SK . set ( sid )
except Exception :
logger . debug ( " per-turn _SESSION_KEY bind failed " , exc_info = True )
return tokens
def _reset_turn_session_identity ( tokens ) - > None :
""" Restore the context-locals bound by ``_set_turn_session_identity`` via
contextvars reset - token semantics .
Reset - token ( not a blanket clear ) is the canonical idiom : it composes
correctly under nesting and restores ` ` _UNSET ` ` for the top - level turn so
a reused thread - pool worker leaks no identity and CLI / cron env fallback
resumes . Order mirrors the bind in reverse .
"""
if not tokens :
return
tok = tokens . get ( " session_key " )
if tok is not None :
try :
from gateway . session_context import _SESSION_KEY as _SK
_SK . reset ( tok )
except Exception :
logger . debug ( " per-turn _SESSION_KEY reset failed " , exc_info = True )
tok = tokens . get ( " approval " )
if tok is not None :
try :
from tools . approval import reset_current_session_key
reset_current_session_key ( tok )
except Exception :
logger . debug ( " per-turn approval session-key reset failed " , exc_info = True )
@contextlib.contextmanager
def _bind_turn_session_identity ( session_id : str ) :
""" Context-manager form of the per-turn session-identity binding.
The ` ` _run_agent_streaming ` ` worker uses the explicit ` ` _set ` ` / ` ` _reset ` `
pair directly because its single ` ` try / finally ` ` already spans the whole
turn ( ~ 2 k lines ) and the binding must cover every mid - turn background
spawn ; this wrapper is the canonical single - call API for other callers and
for tests , and shares the exact same code path .
"""
tokens = _set_turn_session_identity ( session_id )
try :
yield
finally :
_reset_turn_session_identity ( tokens )
2026-06-16 13:58:38 +00:00
def _stale_completion_max_age_seconds ( ) - > float :
""" Max age (seconds) a background-process completion may sit in the queue
before the WebUI drain treats it as stale and drops it instead of
prepending it to the user ' s next turn.
Completions older than this are silently consumed ( not requeued ) so a
notification that finally fires long after the user moved on cannot
contaminate an unrelated later turn . See nesquena / hermes - webui #4029.
Configurable via HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS . A value of
0 ( or negative ) disables age - gating and restores the legacy drain - all
behavior . Defaults to 6 hours .
"""
raw = os . environ . get ( " HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS " )
if raw is not None :
try :
return float ( raw )
except ( TypeError , ValueError ) :
logger . warning (
" Invalid HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS= %r ; using default " ,
raw ,
)
return 6 * 60 * 60 # 6 hours
2026-05-15 00:22:44 +00:00
def _format_process_notification ( evt : dict ) - > str :
""" Format a completed background process notification for agent input. """
if not isinstance ( evt , dict ) :
return ' '
if evt . get ( ' type ' ) != ' completion ' :
return ' '
_sid = evt . get ( ' session_id ' , ' ' )
_cmd = evt . get ( ' command ' , ' ' )
_exit = evt . get ( ' exit_code ' , ' ' )
_out = evt . get ( ' output ' ) or ' '
if len ( _out ) > 4000 :
_out = _out [ : 4000 ] + ' \n ... (truncated) '
return (
f " [IMPORTANT: Background process { _sid } completed (exit code { _exit } ). \n "
f " Command: { _cmd } \n "
f " Output: \n { _out } ] "
)
def _mark_process_completion_consumed ( process_registry , process_id : str ) - > None :
""" Best-effort bridge to the agent registry ' s private completion marker. """
try :
with process_registry . _lock :
process_registry . _completion_consumed . add ( process_id )
except Exception :
logger . debug ( " Failed to mark process completion consumed " , exc_info = True )
def _drain_webui_process_notifications ( session_id : str ) - > list [ str ] :
""" Return completion notifications that belong to this WebUI session.
The agent registry completion queue is process - wide and events do not carry
the WebUI session key directly . Look up the live process session before
delivery so completions from other tabs remain queued for their owners .
"""
if not session_id :
return [ ]
try :
from tools . process_registry import process_registry
except Exception :
return [ ]
notifications : list [ str ] = [ ]
skipped_events : list [ dict ] = [ ]
completion_queue = getattr ( process_registry , ' completion_queue ' , None )
if completion_queue is None :
return [ ]
2026-06-16 13:58:38 +00:00
# Computed once per drain (not per event): reads/validates the env cap a
# single time so an invalid value logs at most one warning per drain.
stale_completion_max_age = _stale_completion_max_age_seconds ( )
2026-05-15 00:22:44 +00:00
while True :
try :
evt = completion_queue . get_nowait ( )
except queue . Empty :
break
except Exception :
logger . debug ( " Failed to drain process completion queue " , exc_info = True )
break
evt_sid = str ( evt . get ( ' session_id ' ) or ' ' ) if isinstance ( evt , dict ) else ' '
if not evt_sid :
skipped_events . append ( evt )
continue
try :
if process_registry . is_completion_consumed ( evt_sid ) :
continue
proc = process_registry . get ( evt_sid )
except Exception :
proc = None
if getattr ( proc , ' session_key ' , None ) != session_id :
skipped_events . append ( evt )
continue
2026-06-16 13:58:38 +00:00
# Age-gate stale completions: a completion that fires long after the
# user moved on must not be prepended to an unrelated later turn
# (nesquena/hermes-webui#4029). Drop (consume, do not requeue) any
# completion whose enqueue time is older than the configured cap.
# Events without a 'completed_at' (older agent builds) are never
# dropped here, preserving backward-compatible behavior.
if stale_completion_max_age > 0 and isinstance ( evt , dict ) :
completed_at = evt . get ( ' completed_at ' )
if isinstance ( completed_at , ( int , float ) ) and completed_at > 0 :
age = time . time ( ) - completed_at
if age > stale_completion_max_age :
logger . info (
" Dropping stale background-process completion for "
" session %s (age %.0f s > cap %.0f s) " ,
evt_sid , age , stale_completion_max_age ,
)
_mark_process_completion_consumed ( process_registry , evt_sid )
continue
2026-05-15 00:22:44 +00:00
notification = _format_process_notification ( evt )
if notification :
notifications . append ( notification )
_mark_process_completion_consumed ( process_registry , evt_sid )
for evt in skipped_events :
try :
completion_queue . put ( evt )
except Exception :
logger . debug ( " Failed to requeue process completion event " , exc_info = True )
break
return notifications
2026-04-28 15:18:51 +00:00
def _attachment_name ( att ) - > str :
if isinstance ( att , dict ) :
return str ( att . get ( ' name ' ) or att . get ( ' filename ' ) or att . get ( ' path ' ) or ' ' ) . strip ( )
return str ( att or ' ' ) . strip ( )
2026-04-29 09:01:01 +00:00
_IMAGE_MAGIC : dict [ bytes | None , frozenset [ str ] ] = {
b ' \x89 PNG \r \n \x1a \n ' : frozenset ( { ' image/png ' } ) ,
b ' \xff \xd8 \xff ' : frozenset ( { ' image/jpeg ' } ) ,
b ' GIF87a ' : frozenset ( { ' image/gif ' } ) ,
b ' GIF89a ' : frozenset ( { ' image/gif ' } ) ,
b ' RIFF ' : frozenset ( { ' image/webp ' } ) ,
b ' BM ' : frozenset ( { ' image/bmp ' } ) ,
None : frozenset ( { ' image/svg+xml ' } ) ,
}
def _is_valid_image ( path : Path , mime : str ) - > bool :
""" Check that the file ' s first bytes match the expected image MIME type.
Uses simple magic - number detection ( no external dependency ) . SVG is
allowed through because it is text - based and has no binary signature .
"""
if not mime . startswith ( ' image/ ' ) :
return False
mime_base = mime . split ( ' ; ' , 1 ) [ 0 ]
if mime_base == ' image/svg+xml ' :
return True
try :
with path . open ( ' rb ' ) as fh :
head = fh . read ( 16 )
except OSError :
return False
for magic , mimes in _IMAGE_MAGIC . items ( ) :
if magic is not None and head . startswith ( magic ) and mime_base in mimes :
return True
return False
2026-05-09 17:39:50 +00:00
def _resolve_image_input_mode ( cfg : dict ) - > str :
""" Return `` " native " `` or `` " text " `` based on config, mirroring
` ` agent / image_routing . py : decide_image_input_mode ` ` .
The agent has this logic , but the WebUI ' s ``_build_native_multimodal_message``
was unconditionally embedding images as native ` ` image_url ` ` parts , completely
bypassing ` ` image_input_mode ` ` . This caused silent failures when the main model
does not support images and the fallback model is also text - only ( #21160-related).
"""
agent_cfg = cfg . get ( " agent " ) or { }
mode = str ( agent_cfg . get ( " image_input_mode " , " auto " ) or " auto " ) . strip ( ) . lower ( )
if mode not in ( " auto " , " native " , " text " ) :
mode = " auto "
if mode == " native " :
return " native "
if mode == " text " :
return " text "
# auto: if auxiliary.vision is explicitly configured → text mode
# (user opted into a dedicated vision backend)
aux = cfg . get ( " auxiliary " ) or { }
vision = aux . get ( " vision " ) or { }
provider = str ( vision . get ( " provider " ) or " " ) . strip ( ) . lower ( )
model_name = str ( vision . get ( " model " ) or " " ) . strip ( )
base_url = str ( vision . get ( " base_url " ) or " " ) . strip ( )
if provider not in ( " " , " auto " ) or model_name or base_url :
return " text "
# No explicit vision config, no model-capability lookup available in WebUI.
# Default to native — the agent's ``_strip_images_from_messages`` guard will
# strip images on rejection and retry as text.
return " native "
def _build_native_multimodal_message ( workspace_ctx : str , msg_text : str , attachments , workspace : str , * , cfg : dict = None ) :
2026-04-28 15:18:51 +00:00
""" Build native multimodal content parts for current-turn image uploads.
WebUI uploads files into the active workspace . For image files , pass the
bytes to Hermes as OpenAI - style image_url data URLs so vision - capable main
models can consume them in the same request . Non - image files intentionally
stay as text path attachments so the agent can inspect them with file tools .
2026-05-09 17:39:50 +00:00
When * cfg * is provided , respects ` ` agent . image_input_mode ` ` — if the resolved
mode is ` ` " text " ` ` , returns a plain string ( attachments are not embedded ) so
the agent ' s text-mode pipeline (``vision_analyze``) handles images.
2026-04-28 15:18:51 +00:00
"""
if not attachments :
return workspace_ctx + msg_text
2026-05-09 17:39:50 +00:00
# ── Check image_input_mode before embedding anything ──
if cfg is not None and _resolve_image_input_mode ( cfg ) == " text " :
return workspace_ctx + msg_text
2026-04-28 15:18:51 +00:00
parts = [ { ' type ' : ' text ' , ' text ' : workspace_ctx + msg_text } ]
workspace_root = Path ( workspace ) . expanduser ( ) . resolve ( )
2026-05-15 19:55:34 +00:00
# Stage-361 maintainer fix (Opus SHOULD-FIX): chat uploads from #2319 now
# land in ~/.hermes/webui/attachments/<sid>/ (outside workspace_root by
# design). The pre-existing `path.relative_to(workspace_root)` guard would
# silently reject every image upload for vision-capable models. Allow the
# configured attachment root in addition to workspace_root so native
# multimodal embeds still build the base64 image_url part. The
# _attachment_root() helper applies expanduser+resolve and is also reused
# by _upload_destination — single source of truth for the inbox root.
try :
from api . upload import _attachment_root
attachment_root = _attachment_root ( )
_allowed_roots = ( workspace_root , attachment_root )
except Exception :
_allowed_roots = ( workspace_root , )
2026-04-28 15:18:51 +00:00
image_count = 0
for att in attachments or [ ] :
if not isinstance ( att , dict ) :
continue
raw_path = str ( att . get ( ' path ' ) or ' ' ) . strip ( )
if not raw_path :
continue
try :
path = Path ( raw_path ) . expanduser ( ) . resolve ( )
2026-05-15 19:55:34 +00:00
# Uploads should live inside the selected workspace OR the
# session attachment inbox (#2319). Do not read arbitrary paths
# from client-provided attachment metadata.
if not any ( path . is_relative_to ( r ) for r in _allowed_roots ) :
continue
2026-04-28 15:18:51 +00:00
if not path . is_file ( ) :
continue
size = path . stat ( ) . st_size
if size < = 0 or size > _NATIVE_IMAGE_MAX_BYTES :
continue
mime = str ( att . get ( ' mime ' ) or ' ' ) . strip ( ) or ( mimetypes . guess_type ( path . name ) [ 0 ] or ' ' )
2026-04-29 09:01:01 +00:00
if not mime . startswith ( ' image/ ' ) or not _is_valid_image ( path , mime ) :
2026-04-28 15:18:51 +00:00
continue
data = base64 . b64encode ( path . read_bytes ( ) ) . decode ( ' ascii ' )
except Exception :
continue
parts . append ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : f ' data: { mime } ;base64, { data } ' } ,
} )
image_count + = 1
return parts if image_count else workspace_ctx + msg_text
2026-04-04 05:13:12 +00:00
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
_INLINE_THINKING_TAG_PAIRS = (
( ' <think> ' , ' </think> ' ) ,
( ' <|channel>thought \n ' , ' <channel|> ' ) ,
( ' <|turn|>thinking \n ' , ' <turn|> ' ) ,
)
def _inline_thinking_fence_marker_at ( text , index ) :
# A fenced code block opener may be indented up to 3 spaces in Markdown
# (4+ spaces is an indented code block, handled separately). The marker is
# only a fence when it sits at the start of a line (after optional 1-3
# spaces of indentation).
if index > 0 and text [ index - 1 ] != ' \n ' :
# Allow up to 3 leading spaces: walk back over spaces to a line start.
back = index - 1
spaces = 0
while back > = 0 and text [ back ] == ' ' and spaces < 3 :
back - = 1
spaces + = 1
if not ( back < 0 or text [ back ] == ' \n ' ) :
return ' '
if text . startswith ( ' ``` ' , index ) :
return ' ``` '
if text . startswith ( ' ~~~ ' , index ) :
return ' ~~~ '
return ' '
Release v0.51.336 — Release KZ (fix per-token inline-thinking perf regression, #3633 follow-up) (#3854)
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)
Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.
Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
streaming) its tail is not a prefix of an opener, return unchanged without the
char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
opener remains ahead, append the remainder and stop instead of walking it
(streaming still suppresses a trailing partial-opener prefix). Handles the
leading-block-then-long-answer case.
Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): perf bulk-skip must respect code context for partial-opener tails
Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix
---------
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
2026-06-09 01:16:52 +00:00
def _next_inline_thinking_opener ( text , start ) :
""" Index of the earliest complete thinking opener at/after `start`, or -1.
Cheap str . find per opener — lets the scanner bulk - skip plain trailing content
instead of walking it char - by - char ( #3633 Codex per-token perf catch)."""
best = - 1
for open_tag , _close in _INLINE_THINKING_TAG_PAIRS :
i = text . find ( open_tag , start )
if i != - 1 and ( best == - 1 or i < best ) :
best = i
return best
def _text_tail_is_partial_opener ( text ) :
""" True when the END of `text` is a non-empty proper prefix of some thinking
opener ( e . g . ` ` < thi ` ` for ` ` < think > ` ` ) . Used to decide whether a streaming
tail might be a forming block worth code - aware handling . """
for open_tag , _close in _INLINE_THINKING_TAG_PAIRS :
m = min ( len ( open_tag ) - 1 , len ( text ) )
for n in range ( m , 0 , - 1 ) :
if open_tag . startswith ( text [ - n : ] ) :
return True
return False
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
def _line_is_indented_code ( text , line_start ) :
""" True when the line beginning at `line_start` is a markdown indented code
block line ( > = 4 leading spaces or a leading tab , and not blank ) . ` line_start `
must be the index of the first character of the line . O ( 1 ) - ish : only inspects
the line ' s leading characters, not the whole document (the per-character
variant was O ( n ^ 2 ) on long no - newline content — #3633 Codex perf catch)."""
if line_start > = len ( text ) :
return False
if text [ line_start ] == ' \t ' :
# A leading tab is indented code only if the line isn't otherwise blank.
nl = text . find ( ' \n ' , line_start )
seg = text [ line_start : ( nl if nl != - 1 else len ( text ) ) ]
return bool ( seg . strip ( ) )
if text . startswith ( ' ' , line_start ) :
nl = text . find ( ' \n ' , line_start )
seg = text [ line_start : ( nl if nl != - 1 else len ( text ) ) ]
return bool ( seg . strip ( ) )
return False
def _merge_inline_thinking_reasoning ( existing_reasoning , extracted_parts ) :
out = str ( existing_reasoning or ' ' ) . strip ( )
for part in extracted_parts or ( ) :
item = str ( part or ' ' ) . strip ( )
if not item :
continue
if not out :
out = item
continue
if out == item or any ( existing . strip ( ) == item for existing in out . split ( ' \n \n ' ) ) :
continue
out = out + ' \n \n ' + item
return out
def _extract_inline_thinking_from_content ( raw_content , existing_reasoning = ' ' , * , streaming = False ) :
""" Split inline thinking blocks out of assistant content.
Code - aware : thinking tags inside a triple - fence ( ` ` ` / ~ ~ ~ ) , an inline
single - backtick code span , or an indented ( > = 4 - space / tab ) code block are
LEFT VISIBLE — they are literal text a user typed / pasted , not a real thinking
trace . ( #3633 deep-review / Codex catch: the earlier full-scan version only
protected triple fences , so a literal ` < think > ` in an inline code span got
silently extracted . )
` ` streaming ` ` gates partial / unclosed - block handling : during live streaming an
unmatched open tag means " still thinking " and its tail is shown as reasoning ;
on the persist / reload path ( streaming = False ) an unclosed tag is LEFT VISIBLE
so prose after a literal ` ` < think > ` ` is never silently truncated on save .
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
"""
text = ' ' if raw_content is None else str ( raw_content )
if not text :
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
return text , str ( existing_reasoning or ' ' ) . strip ( )
Release v0.51.336 — Release KZ (fix per-token inline-thinking perf regression, #3633 follow-up) (#3854)
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)
Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.
Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
streaming) its tail is not a prefix of an opener, return unchanged without the
char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
opener remains ahead, append the remainder and stop instead of walking it
(streaming still suppresses a trailing partial-opener prefix). Handles the
leading-block-then-long-answer case.
Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): perf bulk-skip must respect code context for partial-opener tails
Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix
---------
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
2026-06-09 01:16:52 +00:00
# Fast path (#3633 Codex perf catch — _parseStreamState / syncInflight call
# this on the FULL accumulator on every streamed token, so the common no-tag
# case must not do the O(length) char walk per call). If the text contains no
# complete thinking opener AND — when streaming — its tail is not a prefix of
# any opener (a partial opener mid-stream), there is nothing to extract:
# return the text unchanged. Two cheap substring scans instead of a full walk.
if not any ( open_tag in text for open_tag , _close in _INLINE_THINKING_TAG_PAIRS ) :
tail_is_partial_opener = False
if streaming :
for open_tag , _close in _INLINE_THINKING_TAG_PAIRS :
# Does the END of text look like the START of an opener?
max_prefix = min ( len ( open_tag ) - 1 , len ( text ) )
for n in range ( max_prefix , 0 , - 1 ) :
if open_tag . startswith ( text [ - n : ] ) :
tail_is_partial_opener = True
break
if tail_is_partial_opener :
break
if not tail_is_partial_opener :
return text , str ( existing_reasoning or ' ' ) . strip ( )
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
visible = [ ]
extracted = [ ]
cursor = 0
index = 0
fence = ' '
in_backtick = False
length = len ( text )
# Incremental, O(1)-per-iteration line state (the previous per-character line
# scan made the whole pass O(n^2) on long no-newline content — #3633 Codex
# perf catch). `line_is_indented_code` is recomputed only at a line start.
line_is_indented_code = _line_is_indented_code ( text , 0 )
# Whether any non-whitespace char appeared in text[:index] — the cheap
# equivalent of the old `text[:index].strip() != ''` leading check.
seen_nonspace = False
# Whether a LEADING thinking block/prefix was removed — only then do we
# lstrip the final content (so a reply that legitimately starts with
# indented code / whitespace and has NO leading thinking wrapper keeps its
# leading whitespace — #3633 Codex catch).
leading_removed = False
Release v0.51.336 — Release KZ (fix per-token inline-thinking perf regression, #3633 follow-up) (#3854)
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)
Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.
Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
streaming) its tail is not a prefix of an opener, return unchanged without the
char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
opener remains ahead, append the remainder and stop instead of walking it
(streaming still suppresses a trailing partial-opener prefix). Handles the
leading-block-then-long-answer case.
Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): perf bulk-skip must respect code context for partial-opener tails
Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix
---------
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
2026-06-09 01:16:52 +00:00
# Index of the next opener at/after `index` (recomputed only when we pass it).
# When no opener remains ahead, the rest of the text is plain and can be
# appended in one slice — this keeps a stream that DID contain a leading
# thinking block from re-walking the whole growing answer tail every token
# (#3633 Codex perf catch: the per-token full walk was O(n^2) over a stream).
next_opener = _next_inline_thinking_opener ( text , 0 )
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
while index < length :
Release v0.51.336 — Release KZ (fix per-token inline-thinking perf regression, #3633 follow-up) (#3854)
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)
Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.
Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
streaming) its tail is not a prefix of an opener, return unchanged without the
char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
opener remains ahead, append the remainder and stop instead of walking it
(streaming still suppresses a trailing partial-opener prefix). Handles the
leading-block-then-long-answer case.
Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): perf bulk-skip must respect code context for partial-opener tails
Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix
---------
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
2026-06-09 01:16:52 +00:00
if next_opener == - 1 or index > next_opener :
next_opener = _next_inline_thinking_opener ( text , index )
if next_opener == - 1 :
# No further COMPLETE opener ahead. The remaining tail is plain
# visible content and can be appended in one slice — EXCEPT during
# streaming when the tail is a prefix of an opener (e.g. "...<thi"):
# that may be a forming block and must be suppressed, but ONLY if it
# is outside code context (a partial opener inside inline-backtick /
# fenced / indented code stays visible — master parity). Determining
# code state needs the char walk, so in that case fall through to the
# normal loop (bounded — a partial tail is a transient single token)
# rather than bulk-skipping. Otherwise stop (avoids re-walking the
# growing answer tail every token — #3633 perf catch).
if streaming and _text_tail_is_partial_opener ( text ) :
pass # fall through to the code-aware char walk for the tail
else :
break
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
ch = text [ index ]
if index > 0 and text [ index - 1 ] == ' \n ' :
line_is_indented_code = _line_is_indented_code ( text , index )
marker = _inline_thinking_fence_marker_at ( text , index )
if marker :
fence = ' ' if fence == marker else ( fence or marker )
# Inline single-backtick code span toggles on each lone backtick that is
# not part of a triple fence. Only tracked outside a triple fence.
if not fence and not marker and ch == ' ` ' :
in_backtick = not in_backtick
in_code = bool ( fence ) or in_backtick or line_is_indented_code
if not in_code :
pair = None
for open_tag , close_tag in _INLINE_THINKING_TAG_PAIRS :
if text . startswith ( open_tag , index ) :
pair = ( open_tag , close_tag )
break
if pair :
open_tag , close_tag = pair
close_index = text . find ( close_tag , index + len ( open_tag ) )
if close_index == - 1 :
# Unclosed open tag. A LEADING unclosed block (nothing
# visible before it) is a genuine thinking trace that got
# cut off / persisted mid-thought → reasoning (master #3455
# leading-only intent, and the live-stream "still thinking"
# case). An unclosed tag AFTER visible content on the persist
# path is almost always a literal typed tag — leave it (and
# the prose after it) visible so nothing is silently
# truncated (#3633 Codex catch). During live streaming any
# unmatched open tag is treated as in-progress thinking.
leading = not seen_nonspace
if not streaming and not leading :
break
if leading :
leading_removed = True
visible . append ( text [ cursor : index ] )
partial = text [ index + len ( open_tag ) : ]
if partial :
extracted . append ( partial )
cursor = length
index = length
break
visible . append ( text [ cursor : index ] )
extracted . append ( text [ index + len ( open_tag ) : close_index ] )
if not seen_nonspace :
leading_removed = True
seen_nonspace = True # the extracted tag span is non-whitespace
index = close_index + len ( close_tag )
cursor = index
continue
if streaming :
matched_partial = False
for open_tag , _close_tag in _INLINE_THINKING_TAG_PAIRS :
rest = text [ index : ]
if len ( rest ) < len ( open_tag ) and open_tag . startswith ( rest ) :
if not seen_nonspace :
leading_removed = True
visible . append ( text [ cursor : index ] )
cursor = length
index = length
matched_partial = True
break
if matched_partial or index > = length :
break
if not ch . isspace ( ) :
seen_nonspace = True
index + = 1
if cursor < length :
visible . append ( text [ cursor : ] )
content = ' ' . join ( visible )
if leading_removed :
content = content . lstrip ( )
reasoning = _merge_inline_thinking_reasoning ( existing_reasoning , extracted )
return content , reasoning
def _split_thinking_from_content ( raw_content , existing_reasoning = ' ' ) :
""" Split inline thinking blocks out of assistant content for persistence.
Persistence path : streaming = False , so an unclosed tag stays visible content
( a partial block only means " still thinking " during a live stream ) .
"""
return _extract_inline_thinking_from_content (
raw_content ,
existing_reasoning = existing_reasoning ,
streaming = False ,
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
)
2026-04-15 14:50:59 +00:00
def _strip_thinking_markup ( text : str ) - > str :
""" Remove common reasoning/thinking wrappers from model text. """
if not text :
return ' '
s = str ( text )
2026-05-13 23:13:34 +00:00
# Treat provider thinking wrappers as metadata only when they lead the
# response. Literal discussion of these tags later in normal prose should
# stay visible (#2152).
s = re . sub ( r ' ^ \ s*<think>.*?</think> \ s* ' , ' ' , s , flags = re . IGNORECASE | re . DOTALL )
s = re . sub ( r ' ^ \ s*< \ |channel \ |?>thought \ n?.*?<channel \ |> \ s* ' , ' ' , s , flags = re . IGNORECASE | re . DOTALL )
s = re . sub ( r ' ^ \ s*< \ |turn \ |>thinking \ n.*?<turn \ |> \ s* ' , ' ' , s , flags = re . IGNORECASE | re . DOTALL ) # Gemma 4
2026-05-14 00:42:57 +00:00
s = re . sub ( r ' ^ \ s*(the|ther) \ s+user \ s+is \ s+asking[^ \ n]*(?: \ n|$) ' , ' ' , s , flags = re . IGNORECASE )
2026-04-22 20:20:01 +00:00
# Strip plain-text thinking preambles from models that don't use <think> tags (e.g. Qwen3).
# These appear as the very first sentence of the assistant response and are not useful as titles.
s = re . sub (
r " ^ \ s*(?:here(?: ' s| is) (?:a |my )?(?:thinking|thought) (?:process|trace|through) \ b[^ \ n]* \ n? "
r " |let me (?:think|work|reason|analyze|walk) (?:through|about|this|step) \ b[^ \ n]* \ n? "
r " |i(?: ' ll| will) (?:think|work|reason|analyze|break this down) \ b[^ \ n]* \ n? "
r " |(?:okay|alright|sure|of course),? \ s+let me \ b[^ \ n]* \ n?) " ,
' ' , s , flags = re . IGNORECASE
)
2026-04-15 14:50:59 +00:00
s = re . sub ( r ' \ s+ ' , ' ' , s ) . strip ( )
return s
2026-04-19 05:40:37 +00:00
def _strip_xml_tool_calls ( text : str ) - > str :
""" Strip XML-style function_calls blocks that DeepSeek and similar models
emit in their raw response text . These blocks are processed separately as
tool calls ; leaving them in the assistant content causes them to render
visibly in the chat bubble .
Handles both complete blocks ( < function_calls > … < / function_calls > ) and
partial / orphaned opening tags that may appear at the tail of a stream .
2026-04-24 18:04:16 +00:00
Also handles variants like < | DSML | function_calls > from DeepSeek on Bedrock .
2026-04-19 05:40:37 +00:00
"""
2026-04-24 18:04:16 +00:00
if not text :
2026-04-19 05:40:37 +00:00
return text
s = str ( text )
2026-04-24 18:04:16 +00:00
# Check if contains any function_calls/DSML marker (case-insensitive)
_lo = s . lower ( )
if ' function_calls ' not in _lo and ' dsml ' not in _lo :
return text
_dsml_prefix = r ' (?: \ s*| \ s*DSML \ s*[| |] \ s*)? '
open_tag = rf ' < { _dsml_prefix } function_calls '
close_tag = rf ' </ { _dsml_prefix } function_calls> '
# Strip complete blocks for both <function_calls> and <| DSML| function_calls>.
s = re . sub (
rf ' { open_tag } >.*? { close_tag } ' ,
' ' ,
s ,
flags = re . IGNORECASE | re . DOTALL
)
# Strip orphaned/truncated opening tags, including missing ">" at stream tail.
s = re . sub (
rf ' { open_tag } (?:>|$).*$ ' ,
' ' ,
s ,
flags = re . IGNORECASE | re . DOTALL
)
# Remove malformed DSML fragments like "<| DSML |" that can leak in tokens.
s = re . sub ( r ' < \ s*| \ s*DSML \ s*[| |] \ s* ' , ' ' , s , flags = re . IGNORECASE )
2026-04-19 05:40:37 +00:00
return s . strip ( )
2026-04-15 14:50:59 +00:00
def _sanitize_generated_title ( text : str ) - > str :
""" Sanitize LLM-generated title text before persisting to session. """
s = _strip_thinking_markup ( text or ' ' )
2026-04-17 01:51:00 +00:00
s = re . sub (
2026-04-23 16:45:34 +00:00
r ' ^ \ s*(?:[*_`~]+ \ s*)?(?:session \ s+title|title) \ s*: \ s*(?:[*_`~]+ \ s*)? ' ,
2026-04-17 01:51:00 +00:00
' ' ,
s ,
flags = re . IGNORECASE ,
)
2026-04-15 14:50:59 +00:00
s = re . sub ( r ' ^ \ s*title \ s*: \ s* ' , ' ' , s , flags = re . IGNORECASE )
2026-04-17 01:51:00 +00:00
s = s . strip ( " \t \r \n \" ' `*_~ " )
2026-04-15 14:50:59 +00:00
s = re . sub ( r ' \ s+ ' , ' ' , s ) . strip ( )
# Guard against chain-of-thought leakage and meta-reasoning patterns.
if _looks_invalid_generated_title ( s ) :
return ' '
return s [ : 80 ]
def _looks_invalid_generated_title ( text : str ) - > bool :
s = str ( text or ' ' )
if not s . strip ( ) :
return True
return bool (
2026-04-18 06:45:39 +00:00
re . search ( r ' <think>|< \ |channel \ |>thought|< \ |turn \ |>thinking ' , s , flags = re . IGNORECASE )
2026-04-15 14:50:59 +00:00
or re . search ( r ' ^ \ s*(the|ther) \ s+user \ s+ ' , s , flags = re . IGNORECASE )
or re . search ( r ' ^ \ s*user \ s+ \ w+ \ s+ ' , s , flags = re . IGNORECASE )
or re . search ( r ' \ b(they|user) \ s+want(s)? \ s+me \ s+to \ b ' , s , flags = re . IGNORECASE )
or re . search ( r ' ^ \ s*(i|we) \ s+(should|need to|will|can) \ b ' , s , flags = re . IGNORECASE )
or re . search ( r ' ^ \ s*let me \ b ' , s , flags = re . IGNORECASE )
2026-04-22 20:20:01 +00:00
or re . search ( r " ^ \ s*here(?: ' s| is) (?:a |my )?(?:thinking|thought) " , s , flags = re . IGNORECASE )
2026-04-15 14:50:59 +00:00
or re . search ( r ' ^ \ s*(ok|okay|done|all set|complete|completed|finished) \ b[ \ s.!?]*$ ' , s , flags = re . IGNORECASE )
)
def _message_text ( value ) - > str :
""" Extract plain text from mixed message content payloads. """
if isinstance ( value , list ) :
parts = [ ]
for p in value :
if not isinstance ( p , dict ) :
continue
ptype = str ( p . get ( ' type ' ) or ' ' ) . lower ( )
if ptype in ( ' ' , ' text ' , ' input_text ' , ' output_text ' ) :
parts . append ( str ( p . get ( ' text ' ) or p . get ( ' content ' ) or ' ' ) )
return _strip_thinking_markup ( ' \n ' . join ( parts ) . strip ( ) )
return _strip_thinking_markup ( str ( value or ' ' ) . strip ( ) )
2026-05-08 16:29:22 +00:00
_WORKSPACE_PREFIX_RE = re . compile ( r ' ^ \ s* \ [Workspace::v1: \ s*(?: \\ .|[^ \ ] \\ ])+ \ ] \ s* ' )
_LEGACY_WORKSPACE_PREFIX_RE = re . compile ( r ' ^ \ s* \ [Workspace:[^ \ ]]+ \ ] \ s* ' )
2026-05-12 14:38:29 +00:00
_WORKSPACE_PREFIX_ANY_RE = re . compile ( r ' \ [Workspace::v1: \ s*(?: \\ .|[^ \ ] \\ ])+ \ ] \ s* ' )
_LEGACY_WORKSPACE_PREFIX_ANY_RE = re . compile ( r ' \ [Workspace:[^ \ ]]+ \ ] \ s* ' )
2026-05-08 16:29:22 +00:00
def _escape_workspace_prefix_path ( path : str ) - > str :
return str ( path or ' ' ) . replace ( ' \\ ' , ' \\ \\ ' ) . replace ( ' ] ' , ' \\ ] ' )
def _workspace_context_prefix ( path : str ) - > str :
return f " [Workspace::v1: { _escape_workspace_prefix_path ( path ) } ] \n "
def _strip_workspace_prefix ( text : str , * , include_legacy : bool = False ) - > str :
""" Remove WebUI-injected workspace tags without eating user-typed text. """
value = str ( text or ' ' )
stripped = _WORKSPACE_PREFIX_RE . sub ( ' ' , value , count = 1 )
if include_legacy and stripped == value :
stripped = _LEGACY_WORKSPACE_PREFIX_RE . sub ( ' ' , value , count = 1 )
return stripped . strip ( )
2026-05-08 10:14:13 +00:00
2026-05-12 14:38:29 +00:00
def _looks_like_current_user_turn ( msg , msg_text ) - > bool :
""" Match the current human turn even if an internal workspace tag leaked mid-text.
Normal model - facing messages start with the workspace sentinel . A failed
retry / merge path can also return an optimistic draft followed by the
sentinel and the real prompt . Only treat that shape as the current turn
when the text after the sentinel exactly matches the submitted prompt .
"""
if not isinstance ( msg , dict ) or msg . get ( ' role ' ) != ' user ' :
return False
needle = " " . join ( str ( msg_text or ' ' ) . split ( ) )
if not needle :
return False
text = _message_text ( msg . get ( ' content ' , ' ' ) )
candidates = [ _strip_workspace_prefix ( text , include_legacy = True ) ]
for pattern in ( _WORKSPACE_PREFIX_ANY_RE , _LEGACY_WORKSPACE_PREFIX_ANY_RE ) :
for match in pattern . finditer ( text ) :
candidates . append ( text [ match . end ( ) : ] )
return any ( " " . join ( str ( candidate or ' ' ) . split ( ) ) == needle for candidate in candidates )
2026-04-15 14:50:59 +00:00
def _first_exchange_snippets ( messages ) :
2026-04-18 06:52:45 +00:00
""" Return (first_user_text, first_assistant_text) snippets for title generation.
Prefer the first substantive assistant answer in the opening exchange ,
skipping empty placeholders and assistant tool - call preambles .
"""
2026-04-15 14:50:59 +00:00
user_text = ' '
asst_text = ' '
for m in messages or [ ] :
if not isinstance ( m , dict ) :
continue
role = m . get ( ' role ' )
2026-04-18 06:52:45 +00:00
if role == ' user ' :
candidate = _message_text ( m . get ( ' content ' ) )
if not user_text and candidate :
user_text = candidate
continue
if user_text and candidate :
break
elif role == ' assistant ' and user_text :
2026-04-17 01:51:00 +00:00
candidate = _message_text ( m . get ( ' content ' ) )
2026-04-18 06:52:45 +00:00
# Skip tool-call preambles *only* when content is empty or looks
# like meta-reasoning ("Let me check my memory first.", "The user
# is asking...", etc.). Assistant rows that carry tool_calls but
# also contain a substantive answer text are kept — those are
# agentic first-turn plans that are legitimate title candidates.
if m . get ( ' tool_calls ' ) and ( not candidate or _looks_invalid_generated_title ( candidate ) ) :
continue
2026-04-17 01:51:00 +00:00
if candidate :
asst_text = candidate
2026-04-15 14:50:59 +00:00
if user_text and asst_text :
break
return user_text [ : 500 ] , asst_text [ : 500 ]
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
def _latest_exchange_snippets ( messages ) :
""" Return (last_user_text, last_assistant_text) snippets for title refresh.
Walks the message list backwards to find the last user + assistant pair ,
skipping empty or tool - call - only assistant messages .
"""
user_text = ' '
asst_text = ' '
for m in reversed ( messages or [ ] ) :
if not isinstance ( m , dict ) :
continue
role = m . get ( ' role ' )
if role == ' assistant ' and not asst_text :
candidate = _message_text ( m . get ( ' content ' ) )
# Skip tool-call-only preambles
if m . get ( ' tool_calls ' ) and ( not candidate or _looks_invalid_generated_title ( candidate ) ) :
continue
if candidate :
asst_text = candidate
elif role == ' user ' and not user_text :
candidate = _message_text ( m . get ( ' content ' ) )
if candidate :
user_text = candidate
if user_text and asst_text :
break
return user_text [ : 500 ] , asst_text [ : 500 ]
def _count_exchanges ( messages ) :
""" Count the number of user messages (rough exchange count). """
count = 0
for m in messages or [ ] :
if isinstance ( m , dict ) and m . get ( ' role ' ) == ' user ' :
content = m . get ( ' content ' , ' ' )
if isinstance ( content , list ) :
content = ' ' . join ( p . get ( ' text ' , ' ' ) for p in content if isinstance ( p , dict ) and p . get ( ' type ' ) == ' text ' )
if str ( content ) . strip ( ) :
count + = 1
return count
def _get_title_refresh_interval ( ) - > int :
""" Read the auto_title_refresh_every setting (0 = disabled). """
try :
from api . config import load_settings
settings = load_settings ( )
val = settings . get ( ' auto_title_refresh_every ' , ' 0 ' )
return int ( val ) if str ( val ) . strip ( ) . isdigit ( ) and int ( val ) > 0 else 0
except Exception :
return 0
2026-04-15 14:50:59 +00:00
def _is_provisional_title ( current_title : str , messages ) - > bool :
""" Heuristic: title equals first-message substring placeholder. """
derived = title_from ( messages , ' ' ) or ' '
if not derived :
return False
2026-04-18 06:52:45 +00:00
current = re . sub ( r ' \ s+ ' , ' ' , str ( current_title or ' ' ) ) . strip ( )
candidate = re . sub ( r ' \ s+ ' , ' ' , str ( derived [ : 64 ] or ' ' ) ) . strip ( )
if not current or not candidate :
return False
2026-05-13 15:38:07 +00:00
return current == candidate
2026-04-15 14:50:59 +00:00
2026-05-28 02:47:40 +00:00
def _detect_title_language ( text : str ) - > str :
""" Best-effort language hint for title generation/validation. """
s = re . sub ( r ' \ s+ ' , ' ' , str ( text or ' ' ) ) . strip ( ) . lower ( )
if not s :
return ' '
german_markers = {
' warum ' , ' werden ' , ' wird ' , ' wurde ' , ' hier ' , ' nicht ' , ' mehr ' , ' alte ' , ' alten ' ,
2026-05-28 06:21:58 +00:00
' bilder ' , ' angezeigt ' , ' prüfe ' , ' ich ' , ' und ' , ' oder ' , ' mit ' , ' für ' , ' von ' ,
' zu ' , ' ist ' , ' sind ' , ' bitte ' , ' kannst ' ,
2026-05-28 02:47:40 +00:00
}
tokens = re . findall ( r ' [A-Za-zÀ-ÖØ-öø-ÿ]+ ' , s )
german_hits = sum ( 1 for tok in tokens if tok in german_markers )
2026-05-28 06:21:58 +00:00
if re . search ( r ' [äöüß] ' , s ) or german_hits > = 3 :
2026-05-28 02:47:40 +00:00
return ' de '
return ' '
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
def _script_counts ( text : str ) - > dict :
""" Return per-script alphabetic character counts for *text*.
Buckets : ` ` latin ` ` , ` ` cjk ` ` ( Han / Hiragana / Katakana / Hangul ) , ` ` cyrillic ` ` ,
` ` arabic ` ` , ` ` hebrew ` ` , ` ` greek ` ` , ` ` devanagari ` ` . Non - alphabetic and
unclassified characters are ignored .
"""
counts : dict [ str , int ] = { }
for ch in str ( text or ' ' ) :
if not ch . isalpha ( ) :
continue
o = ord ( ch )
if ( 0x0041 < = o < = 0x024F ) or ( 0x1E00 < = o < = 0x1EFF ) :
bucket = ' latin '
elif (
( 0x4E00 < = o < = 0x9FFF ) or ( 0x3400 < = o < = 0x4DBF ) # Han
or ( 0x3040 < = o < = 0x30FF ) # Hiragana/Katakana
or ( 0xAC00 < = o < = 0xD7A3 ) or ( 0x1100 < = o < = 0x11FF ) # Hangul
) :
bucket = ' cjk '
elif 0x0400 < = o < = 0x04FF :
bucket = ' cyrillic '
elif ( 0x0600 < = o < = 0x06FF ) or ( 0x0750 < = o < = 0x077F ) :
bucket = ' arabic '
elif 0x0590 < = o < = 0x05FF :
bucket = ' hebrew '
elif 0x0370 < = o < = 0x03FF :
bucket = ' greek '
elif 0x0900 < = o < = 0x097F :
bucket = ' devanagari '
else :
continue
counts [ bucket ] = counts . get ( bucket , 0 ) + 1
return counts
def _dominant_script ( text : str ) - > str :
""" Return a coarse writing-script bucket for *text*, or ' ' when undecidable.
Script - level ( not language - level ) classification is cheap and dependency - free .
Returns the dominant script only when it holds a clear ( ≥ 60 % ) majority of the
alphabetic characters , so mixed / borrowed text doesn ' t flip the bucket. Used
to establish the conversation start ' s expected script for cross-script title
drift detection ( #3293).
"""
counts = _script_counts ( text )
total = sum ( counts . values ( ) )
if total < 2 :
return ' '
top , top_n = max ( counts . items ( ) , key = lambda kv : kv [ 1 ] )
if top_n / total > = 0.6 :
return top
return ' '
2026-05-28 02:47:40 +00:00
def _title_prompt_language_rule ( user_text : str ) - > str :
return " Match the language of the user question. \n "
def _title_language_mismatch ( user_text : str , title : str ) - > bool :
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
""" Reject titles whose language clearly diverges from the conversation start.
Two independent signals :
1. Cross - script drift ( #3293): when the conversation start has a clear
dominant writing script ( e . g . latin / English ) and the generated title
introduces a * substantial * amount of a different script ( e . g . CJK or
Cyrillic ) , reject . This is language - agnostic and catches the common
" English chat -> Chinese/Spanish/Russian title " drift . Because titles are
short and frequently embed a borrowed Latin technical term ( e . g . a CJK
title containing the word " Python " ) , the title side uses a proportion
threshold ( > = 35 % of the title ' s alphabetic characters in a non-start
script , min 2 chars ) rather than a strict majority - - so a CJK title with
one English word still trips , while an English title with a single
foreign place - name does not .
2. The legacy German - start → English - title heuristic , preserved verbatim so
the original behavior keeps working for same - script ( latin ) drift that
the script check can ' t see.
"""
candidate = str ( title or ' ' ) . strip ( )
2026-05-28 02:47:40 +00:00
if not candidate :
return False
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
# (1) Cross-script mismatch — language-agnostic.
user_script = _dominant_script ( user_text )
if user_script :
title_counts = _script_counts ( candidate )
title_total = sum ( title_counts . values ( ) )
if title_total > = 2 :
for script , n in title_counts . items ( ) :
if script != user_script and n > = 2 and ( n / title_total ) > = 0.35 :
return True
# (2) Legacy same-script German→English heuristic.
if _detect_title_language ( user_text ) != ' de ' :
return False
candidate_lower = candidate . lower ( )
if _detect_title_language ( candidate_lower ) == ' de ' :
2026-05-28 02:47:40 +00:00
return False
english_markers = {
' old ' , ' image ' , ' display ' , ' issue ' , ' problem ' , ' discussion ' , ' conversation ' ,
' session ' , ' title ' , ' fix ' , ' bug ' , ' attachment ' , ' attachments ' , ' context ' ,
}
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
tokens = re . findall ( r ' [a-z]+ ' , candidate_lower )
2026-05-28 02:47:40 +00:00
english_hits = sum ( 1 for tok in tokens if tok in english_markers )
return english_hits > = 2
2026-04-15 14:50:59 +00:00
def _title_prompts ( user_text : str , assistant_text : str ) - > tuple [ str , list [ str ] ] :
qa = f " User question: \n { user_text [ : 500 ] } \n \n Assistant answer: \n { assistant_text [ : 500 ] } "
2026-05-28 02:47:40 +00:00
language_rule = _title_prompt_language_rule ( user_text )
2026-04-15 14:50:59 +00:00
prompts = [
(
" Generate a short session title from this conversation start. \n "
" Use BOTH the user ' s question and the assistant ' s visible answer. \n "
2026-05-28 02:47:40 +00:00
f " { language_rule } "
2026-04-15 14:50:59 +00:00
" Return only the title text, 3-8 words, as a topic label. \n "
2026-04-17 01:51:00 +00:00
" Do not use markdown, bullets, labels, or prefixes like Session Title:. \n "
2026-04-15 14:50:59 +00:00
" Do not output a full sentence. \n "
2026-04-23 16:45:34 +00:00
" Do not output acknowledgements or completion phrases like OK, done, or all set. \n "
2026-04-15 14:50:59 +00:00
" Do not describe internal reasoning. \n "
2026-04-23 16:45:34 +00:00
" Bad: The user is asking..., OK, all set. \n "
" Good: Title Generation Test, Clarify Dialog Layout, GitHub Issue Triage "
2026-04-15 14:50:59 +00:00
) ,
(
" Rewrite this conversation start as a concise noun-phrase title. \n "
" Use the actual topic, not the task outcome. \n "
2026-05-28 02:47:40 +00:00
f " { language_rule } "
2026-04-15 14:50:59 +00:00
" Return title text only. \n "
2026-04-17 01:51:00 +00:00
" Do not use markdown, bullets, labels, or prefixes like Session Title:. \n "
2026-04-15 14:50:59 +00:00
" Never output acknowledgements, completion status, or meta commentary. "
) ,
]
return qa , prompts
def _is_minimax_route ( provider : str = ' ' , model : str = ' ' , base_url : str = ' ' ) - > bool :
text = ' ' . join ( [
str ( provider or ' ' ) . lower ( ) ,
str ( model or ' ' ) . lower ( ) ,
str ( base_url or ' ' ) . lower ( ) ,
] )
return ' minimax ' in text or ' minimaxi.com ' in text
2026-06-14 04:51:48 +00:00
def _route_rejects_reasoning_extra ( provider : str = ' ' , model : str = ' ' , base_url : str = ' ' ) - > bool :
""" Routes known to reject an ``extra_body`` ``reasoning`` parameter with HTTP 400.
Title generation injects ` ` extra_body = { " reasoning " : { " enabled " : False } } ` ` to
suppress thinking on reasoning - capable models ( #2083). But OpenAI Chat
Completions ( and Azure OpenAI ) reject unknown top - level params with a 400 , so
that inject silently fails the title call and falls back to a low - quality
heuristic title ( #4161). Skip the inject for those routes.
OpenRouter Anthropic mandatory - reasoning models ( Claude Sonnet 4.6 / Opus 4.8 )
are reasoning - capable but reject a reasoning * disable * — title gen only needs
reasoning off , so skip the inject for them too rather than risk the same 400.
"""
provider_lower = str ( provider or ' ' ) . strip ( ) . lower ( )
model_lower = str ( model or ' ' ) . strip ( ) . lower ( )
# Hostname-based match (not substring) so a proxy URL that merely *contains*
# one of these strings in a path segment isn't mis-classified.
host = ' '
try :
from urllib . parse import urlsplit
host = ( urlsplit ( str ( base_url or ' ' ) . strip ( ) ) . hostname or ' ' ) . lower ( )
except Exception :
host = ' '
if host == ' api.openai.com ' or host . endswith ( ' .openai.azure.com ' ) :
return True
# Azure AI Foundry chat-completions hosts (also reject the reasoning param).
if host . endswith ( ' .services.ai.azure.com ' ) or host . endswith ( ' .cognitiveservices.azure.com ' ) :
return True
if provider_lower in ( ' openai ' , ' openai-api ' , ' openai-codex ' ) :
return True
if (
provider_lower in ( ' azure ' , ' azure-foundry ' , ' azure-ai-foundry ' , ' azure-ai ' )
or provider_lower . startswith ( ' azure/ ' )
or provider_lower . startswith ( ' azure- ' )
) :
return True
if ( host == ' openrouter.ai ' or host . endswith ( ' .openrouter.ai ' ) ) and model_lower . startswith ( ' anthropic/ ' ) :
# Anthropic on OpenRouter: mandatory-reasoning families reject a disable.
return True
return False
2026-05-20 02:35:11 +00:00
def _get_aux_title_config ( ) - > dict :
""" Return title_generation auxiliary config, or an empty dict on errors. """
2026-04-24 01:07:02 +00:00
try :
from agent . auxiliary_client import _get_auxiliary_task_config
tg = _get_auxiliary_task_config ( ' title_generation ' )
2026-05-20 02:35:11 +00:00
return tg if isinstance ( tg , dict ) else { }
2026-04-24 01:07:02 +00:00
except Exception :
2026-05-20 02:35:11 +00:00
return { }
def _aux_title_configured ( ) - > bool :
""" Return True when any auxiliary title_generation config field is meaningfully set. """
tg = _get_aux_title_config ( )
provider = tg . get ( ' provider ' , ' ' ) or ' '
model = tg . get ( ' model ' , ' ' ) or ' '
base_url = tg . get ( ' base_url ' , ' ' ) or ' '
return bool ( model or base_url or ( provider and provider . lower ( ) != ' auto ' ) )
2026-04-24 01:07:02 +00:00
def _aux_title_timeout ( default : float = 15.0 ) - > float :
""" Return the configured timeout (seconds) for auxiliary title generation.
Only accepts positive numeric values . Falls back to * default * when the
value is ` ` None ` ` , non - numeric , zero , or negative , and emits a debug log
so mis - configurations are visible in server output .
"""
try :
2026-05-20 02:35:11 +00:00
tg = _get_aux_title_config ( )
2026-04-24 01:07:02 +00:00
raw = tg . get ( ' timeout ' )
if raw is None :
return default
try :
value = float ( raw )
except ( ValueError , TypeError ) :
logger . debug ( " aux title timeout: non-numeric value %r , falling back to %s " , raw , default )
return default
if value > 0 :
return value
logger . debug ( " aux title timeout: non-positive value %s , falling back to %s " , value , default )
return default
except Exception :
return default
2026-04-15 14:50:59 +00:00
def _title_completion_budget ( provider : str = ' ' , model : str = ' ' , base_url : str = ' ' ) - > int :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
# Title generation is a small auxiliary task, but reasoning models may
# spend a surprising amount of the completion budget before emitting final
# content. Keep the budget high enough for MiniMax/Kimi-style reasoning
# responses without making title generation depend on provider-specific
# one-off branches.
return 512
def _title_retry_completion_budget ( provider : str = ' ' , model : str = ' ' , base_url : str = ' ' ) - > int :
return max ( 1024 , _title_completion_budget ( provider , model , base_url ) * 2 )
def _title_retry_status ( status : str ) - > bool :
2026-05-11 23:26:19 +00:00
# Whether to grant a second budget attempt within the same prompt+model
# combination. ``llm_length`` indicates the model would have produced
# content with more headroom, so doubling the budget can help.
#
# ``llm_empty_reasoning`` historically also triggered a retry, but for
# reasoning models (Qwen3-thinking, DeepSeek-R1, Kimi-K2, etc.) that
# status means the model burned its entire budget on hidden reasoning
# tokens and emitted nothing visible. Doubling the budget in that case
# just doubles the GPU/credit cost without changing the outcome — the
# next attempt produces the same shape. We skip the retry for empty-
# reasoning statuses and let the title path fall through to the local
# fallback summary. See issue #2083 for the LM Studio + Qwen3 repro.
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
return status in {
' llm_length ' ,
' llm_length_aux ' ,
2026-05-11 23:26:19 +00:00
}
def _title_should_skip_remaining_attempts ( status : str ) - > bool :
2026-05-12 00:16:33 +00:00
""" Statuses where re-issuing the next prompt against the same model
produces the same failing shape ( model burned its budget on hidden
reasoning , hit a hard provider gate , etc . ) .
Short - circuit the prompt - iteration loop so we don ' t issue a second
full - budget LLM call ( and twice the GPU / credit burn ) only to land in
the same fallback path . See issue #2083.
Add a status here only when retrying the next prompt is provably
wasted work ( single - call signal already establishes that the next
call will return the same shape ) . Length - truncation WITHOUT
reasoning is NOT in the set — that ' s legitimately recoverable by
a larger budget on a different prompt and stays in
: func : ` _title_retry_status ` .
"""
2026-05-11 23:26:19 +00:00
return status in {
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
' llm_empty_reasoning ' ,
' llm_empty_reasoning_aux ' ,
}
def _safe_obj_value ( obj , key : str ) :
if obj is None :
return None
if isinstance ( obj , dict ) :
return obj . get ( key )
value = getattr ( obj , key , None )
# Missing MagicMock attrs stringify as mock reprs and look truthy. Treat
# them as absent so tests model real provider objects accurately.
if value . __class__ . __module__ . startswith ( ' unittest.mock ' ) :
return None
return value
def _safe_text_value ( value ) - > str :
if value is None :
return ' '
if value . __class__ . __module__ . startswith ( ' unittest.mock ' ) :
return ' '
return str ( value or ' ' ) . strip ( )
def _extract_title_response ( resp , * , aux : bool = False ) - > tuple [ str , str ] :
""" Return (content, empty_status) from an OpenAI-compatible response. """
suffix = ' _aux ' if aux else ' '
try :
choices = _safe_obj_value ( resp , ' choices ' ) or [ ]
choice = choices [ 0 ] if choices else None
message = _safe_obj_value ( choice , ' message ' )
content = _safe_text_value ( _safe_obj_value ( message , ' content ' ) )
if content :
return content , ' '
finish_reason = _safe_text_value ( _safe_obj_value ( choice , ' finish_reason ' ) ) . lower ( )
reasoning = (
_safe_text_value ( _safe_obj_value ( message , ' reasoning ' ) )
or _safe_text_value ( _safe_obj_value ( message , ' reasoning_content ' ) )
or _safe_text_value ( _safe_obj_value ( message , ' thinking ' ) )
)
2026-05-11 23:26:19 +00:00
# When the model emitted reasoning tokens but no visible content, it
# burned its budget on hidden thinking — retrying with a larger budget
# almost never recovers a useful title (see issue #2083: Qwen3-thinking
# via LM Studio loops indefinitely on auto-title generation). Report
# this case distinctly so callers can short-circuit instead of double-
# billing the GPU/credit on a near-certain repeat.
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
if reasoning :
return ' ' , f ' llm_empty_reasoning { suffix } '
2026-05-11 23:26:19 +00:00
if finish_reason == ' length ' :
return ' ' , f ' llm_length { suffix } '
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
return ' ' , f ' llm_empty { suffix } '
except Exception :
return ' ' , f ' llm_empty { suffix } '
2026-04-15 14:50:59 +00:00
def generate_title_raw_via_aux (
user_text : str ,
assistant_text : str ,
provider : str = ' ' ,
model : str = ' ' ,
base_url : str = ' ' ,
) - > tuple [ Optional [ str ] , str ] :
""" Return (raw_text, status) via auxiliary LLM route. """
if not user_text or not assistant_text :
return None , ' missing_exchange '
qa , prompts = _title_prompts ( user_text , assistant_text )
2026-05-20 02:35:11 +00:00
configured = _get_aux_title_config ( )
2026-05-20 19:52:24 +00:00
caller_supplied_route = bool ( provider or model or base_url )
2026-05-20 02:35:11 +00:00
provider = provider or configured . get ( ' provider ' , ' ' ) or ' '
if str ( provider ) . strip ( ) . lower ( ) == ' auto ' :
provider = ' '
model = model or configured . get ( ' model ' , ' ' ) or ' '
base_url = base_url or configured . get ( ' base_url ' , ' ' ) or ' '
2026-06-02 21:14:49 +00:00
try :
from api . profiles import _split_webui_provider_model_value
normalized_model , normalized_provider = _split_webui_provider_model_value (
model or None ,
provider or None ,
)
model = normalized_model or ' '
provider = normalized_provider or ' '
except ValueError :
pass
2026-05-20 19:52:24 +00:00
api_key = ' '
if not caller_supplied_route :
api_key = str ( configured . get ( ' api_key ' , ' ' ) or ' ' ) . strip ( )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
base_max_tokens = _title_completion_budget ( provider , model , base_url )
2026-06-14 04:51:48 +00:00
reasoning_extra = { }
if not _route_rejects_reasoning_extra ( provider , model , base_url ) :
reasoning_extra [ " reasoning " ] = { " enabled " : False }
2026-04-15 14:50:59 +00:00
if _is_minimax_route ( provider , model , base_url ) :
reasoning_extra [ " reasoning_split " ] = True
try :
2026-04-24 01:07:02 +00:00
_timeout = _aux_title_timeout ( )
2026-04-15 14:50:59 +00:00
from agent . auxiliary_client import call_llm
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
last_status = ' llm_error_aux '
2026-04-15 14:50:59 +00:00
for idx , prompt in enumerate ( prompts ) :
messages = [
{ " role " : " system " , " content " : prompt } ,
{ " role " : " user " , " content " : qa } ,
]
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
budgets = [ base_max_tokens ]
2026-04-15 14:50:59 +00:00
try :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
for budget_idx , max_tokens in enumerate ( budgets ) :
resp = call_llm (
task = ' title_generation ' ,
provider = provider or None ,
model = model or None ,
base_url = base_url or None ,
2026-05-20 19:52:24 +00:00
api_key = api_key or None ,
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
messages = messages ,
max_tokens = max_tokens ,
temperature = 0.2 ,
timeout = _timeout ,
2026-06-14 04:51:48 +00:00
extra_body = reasoning_extra or None ,
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
)
raw , empty_status = _extract_title_response ( resp , aux = True )
if raw :
return raw , ( ' llm_aux ' if idx == 0 and budget_idx == 0 else ' llm_aux_retry ' )
last_status = empty_status or ' llm_empty_aux '
if budget_idx == 0 and _title_retry_status ( last_status ) :
budgets . append ( _title_retry_completion_budget ( provider , model , base_url ) )
2026-04-15 14:50:59 +00:00
except Exception as e :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
last_status = ' llm_error_aux '
2026-04-15 14:50:59 +00:00
logger . debug ( " Aux title generation attempt %s failed: %s " , idx + 1 , e )
2026-05-11 23:26:19 +00:00
# If the model just burned its budget on hidden reasoning, retrying
# the next prompt against the same model produces the same shape.
# Short-circuit to the local fallback path (#2083).
if _title_should_skip_remaining_attempts ( last_status ) :
2026-05-12 00:16:33 +00:00
logger . debug (
" Aux title generation short-circuiting after %s (reasoning-only response). " ,
last_status ,
)
2026-05-11 23:26:19 +00:00
break
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
return None , last_status
2026-04-15 14:50:59 +00:00
except Exception as e :
logger . debug ( " Aux title generation failed: %s " , e )
return None , ' llm_error_aux '
def generate_title_raw_via_agent ( agent , user_text : str , assistant_text : str ) - > tuple [ Optional [ str ] , str ] :
""" Return (raw_text, status) via active-agent route. """
if not user_text or not assistant_text :
return None , ' missing_exchange '
if agent is None :
return None , ' missing_agent '
qa , prompts = _title_prompts ( user_text , assistant_text )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
base_max_tokens = _title_completion_budget (
2026-04-15 14:50:59 +00:00
getattr ( agent , ' provider ' , ' ' ) ,
getattr ( agent , ' model ' , ' ' ) ,
getattr ( agent , ' base_url ' , ' ' ) ,
)
disabled_reasoning = { " enabled " : False }
prev_reasoning = getattr ( agent , ' reasoning_config ' , None )
try :
agent . reasoning_config = disabled_reasoning
for idx , prompt in enumerate ( prompts ) :
api_messages = [
{ " role " : " system " , " content " : prompt } ,
{ " role " : " user " , " content " : qa } ,
]
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
budgets = [ base_max_tokens ]
2026-04-15 14:50:59 +00:00
try :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
last_status = ' llm_empty '
for budget_idx , max_tokens in enumerate ( budgets ) :
raw = " "
empty_status = ' '
if getattr ( agent , ' api_mode ' , ' ' ) == ' codex_responses ' :
codex_kwargs = agent . _build_api_kwargs ( api_messages )
codex_kwargs . pop ( ' tools ' , None )
if ' max_output_tokens ' in codex_kwargs :
codex_kwargs [ ' max_output_tokens ' ] = max_tokens
resp = agent . _run_codex_stream ( codex_kwargs )
assistant_message , _ = agent . _normalize_codex_response ( resp )
raw = ( assistant_message . content or ' ' ) if assistant_message else ' '
if not raw :
empty_status = ' llm_empty '
elif getattr ( agent , ' api_mode ' , ' ' ) == ' anthropic_messages ' :
from agent . anthropic_adapter import build_anthropic_kwargs , normalize_anthropic_response
ant_kwargs = build_anthropic_kwargs (
model = agent . model ,
messages = api_messages ,
tools = None ,
max_tokens = max_tokens ,
reasoning_config = disabled_reasoning ,
is_oauth = getattr ( agent , ' _is_anthropic_oauth ' , False ) ,
preserve_dots = agent . _anthropic_preserve_dots ( ) ,
base_url = getattr ( agent , ' _anthropic_base_url ' , None ) ,
)
resp = agent . _anthropic_messages_create ( ant_kwargs )
assistant_message , _ = normalize_anthropic_response (
resp , strip_tool_prefix = getattr ( agent , ' _is_anthropic_oauth ' , False )
)
raw = ( assistant_message . content or ' ' ) if assistant_message else ' '
if not raw :
empty_status = ' llm_empty '
2026-04-15 14:50:59 +00:00
else :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
api_kwargs = agent . _build_api_kwargs ( api_messages )
api_kwargs . pop ( ' tools ' , None )
api_kwargs [ ' temperature ' ] = 0.1
api_kwargs [ ' timeout ' ] = 15.0
2026-06-14 04:51:48 +00:00
# Reasoning suppression for title gen is already handled
# route-correctly by `_build_api_kwargs()` from the
# `agent.reasoning_config = {"enabled": False}` set above —
# each provider profile applies (or deliberately omits) the
# disable in the form its endpoint accepts (OpenAI/Nous omit
# the field; LM Studio uses top-level reasoning_effort;
# OpenRouter Anthropic mandatory-reasoning is omitted). Do NOT
# re-inject a generic `reasoning:{enabled:False}` here — that
# re-adds a 400-rejected param on top of the profile output
# (#4161). MiniMax still needs reasoning_split, which the
# profile path does not add.
_tg_extra = dict ( api_kwargs . get ( ' extra_body ' ) or { } )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
if _is_minimax_route ( getattr ( agent , ' provider ' , ' ' ) , getattr ( agent , ' model ' , ' ' ) , getattr ( agent , ' base_url ' , ' ' ) ) :
2026-06-14 04:51:48 +00:00
_tg_extra [ ' reasoning_split ' ] = True
if _tg_extra :
api_kwargs [ ' extra_body ' ] = _tg_extra
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
if ' max_completion_tokens ' in api_kwargs :
api_kwargs [ ' max_completion_tokens ' ] = max_tokens
else :
api_kwargs [ ' max_tokens ' ] = max_tokens
resp = agent . _ensure_primary_openai_client ( reason = ' title_generation ' ) . chat . completions . create (
* * api_kwargs ,
)
raw , empty_status = _extract_title_response ( resp )
raw = str ( raw or ' ' ) . strip ( )
if raw :
return raw , ( ' llm ' if idx == 0 and budget_idx == 0 else ' llm_retry ' )
last_status = empty_status or ' llm_empty '
if budget_idx == 0 and _title_retry_status ( last_status ) :
budgets . append ( _title_retry_completion_budget (
getattr ( agent , ' provider ' , ' ' ) ,
getattr ( agent , ' model ' , ' ' ) ,
getattr ( agent , ' base_url ' , ' ' ) ,
) )
2026-04-15 14:50:59 +00:00
except Exception as e :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
last_status = ' llm_error '
2026-04-15 14:50:59 +00:00
logger . debug (
" Agent title generation attempt %s failed: provider= %s model= %s error= %s " ,
idx + 1 ,
getattr ( agent , ' provider ' , None ) ,
getattr ( agent , ' model ' , None ) ,
e ,
)
2026-05-11 23:26:19 +00:00
# If the model just burned its budget on hidden reasoning, retrying
# the next prompt against the same model produces the same shape.
# Short-circuit to the local fallback path (#2083).
if _title_should_skip_remaining_attempts ( last_status ) :
2026-05-12 00:16:33 +00:00
logger . debug (
" Agent title generation short-circuiting after %s (reasoning-only response). " ,
last_status ,
)
2026-05-11 23:26:19 +00:00
break
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
return None , last_status
2026-04-15 14:50:59 +00:00
except Exception as e :
logger . debug ( " Agent title generation failed: %s " , e )
return None , ' llm_error '
finally :
agent . reasoning_config = prev_reasoning
def _generate_llm_session_title_for_agent ( agent , user_text : str , assistant_text : str ) - > tuple [ Optional [ str ] , str , str ] :
""" Generate a title via active-agent route, then sanitize/validate result. """
raw , status = generate_title_raw_via_agent ( agent , user_text , assistant_text )
if not raw :
return None , status , ' '
title = _sanitize_generated_title ( raw )
if title :
2026-05-28 02:47:40 +00:00
if _title_language_mismatch ( user_text , title ) :
return None , ' llm_language_mismatch ' , str ( raw ) [ : 120 ]
2026-04-15 14:50:59 +00:00
return title , status , ' '
return None , ' llm_invalid ' , str ( raw ) [ : 120 ]
2026-04-24 01:07:02 +00:00
def _generate_llm_session_title_via_aux ( user_text : str , assistant_text : str , agent = None , * , use_agent_model : bool = False ) - > tuple [ Optional [ str ] , str , str ] :
""" Generate a title via dedicated auxiliary LLM route, then sanitize/validate result.
When use_agent_model is False ( default ) , the auxiliary client resolves
provider / model / base_url from config . yaml auxiliary . title_generation , which
prevents the session ' s chat model (e.g. a Chinese model) from overriding
the dedicated title model . When True , the agent ' s attrs are passed through
( legacy fallback behaviour ) .
"""
if use_agent_model and agent :
provider = getattr ( agent , ' provider ' , ' ' )
model = getattr ( agent , ' model ' , ' ' )
base_url = getattr ( agent , ' base_url ' , ' ' )
else :
provider = ' '
model = ' '
base_url = ' '
2026-04-15 14:50:59 +00:00
raw , status = generate_title_raw_via_aux (
user_text ,
assistant_text ,
2026-04-24 01:07:02 +00:00
provider = provider ,
model = model ,
base_url = base_url ,
2026-04-15 14:50:59 +00:00
)
if not raw :
return None , status , ' '
title = _sanitize_generated_title ( raw )
if title :
2026-05-28 02:47:40 +00:00
if _title_language_mismatch ( user_text , title ) :
return None , ' llm_language_mismatch_aux ' , str ( raw ) [ : 120 ]
2026-04-15 14:50:59 +00:00
return title , status , ' '
return None , ' llm_invalid_aux ' , str ( raw ) [ : 120 ]
def _put_title_status ( put_event , session_id : str , status : str , reason : str = ' ' , title : str = ' ' , raw_preview : str = ' ' ) - > None :
payload = { ' session_id ' : session_id , ' status ' : status }
if reason :
payload [ ' reason ' ] = reason
if title :
payload [ ' title ' ] = title
if raw_preview :
payload [ ' raw_preview ' ] = raw_preview
put_event ( ' title_status ' , payload )
logger . info (
" title_status session= %s status= %s reason= %s title= %r raw_preview= %r " ,
session_id ,
status ,
reason or ' - ' ,
title or ' ' ,
( raw_preview or ' ' ) [ : 120 ] ,
)
def _fallback_title_from_exchange ( user_text : str , assistant_text : str ) - > Optional [ str ] :
""" Generate a readable local fallback title when LLM title generation fails. """
user_text = ( user_text or ' ' ) . strip ( )
assistant_text = _strip_thinking_markup ( assistant_text or ' ' ) . strip ( )
if not user_text :
return None
2026-05-08 16:29:22 +00:00
user_text = _strip_workspace_prefix ( user_text )
2026-04-15 14:50:59 +00:00
user_text = re . sub ( r ' \ s+ ' , ' ' , user_text ) . strip ( )
assistant_text = re . sub ( r ' \ s+ ' , ' ' , assistant_text ) . strip ( )
combined = f " { user_text } { assistant_text } " . strip ( ) . lower ( )
combined_raw = f " { user_text } { assistant_text } " . strip ( )
2026-04-23 16:45:34 +00:00
def _contains_latin ( text : str ) - > bool :
return bool ( re . search ( r ' [A-Za-z] ' , text or ' ' ) )
2026-04-15 14:50:59 +00:00
def _extract_named_topic ( text : str ) - > str :
m = re . search ( r ' " ([^ " \ n] { 2,24}) " ' , text )
if m :
return ( m . group ( 1 ) or ' ' ) . strip ( )
m = re . search ( r ' “([^” \ n] { 2,24})” ' , text )
if m :
return ( m . group ( 1 ) or ' ' ) . strip ( )
return ' '
topic_name = _extract_named_topic ( combined_raw )
if topic_name :
2026-04-23 16:45:34 +00:00
if not _contains_latin ( topic_name ) :
if any ( k in combined for k in ( ' time ' , ' schedule ' , ' efficiency ' , ' manage ' , ' fitness ' , ' singing ' , ' calligraphy ' ) ) :
return ' Time management discussion '
if any ( k in combined for k in ( ' hermes ' , ' codex ' , ' ai ' ) ) :
return ' AI productivity discussion '
return ' Conversation topic '
if any ( k in combined for k in ( ' time ' , ' schedule ' , ' efficiency ' , ' manage ' , ' fitness ' , ' singing ' , ' calligraphy ' ) ) :
return f ' { topic_name } time management '
2026-04-15 14:50:59 +00:00
if any ( k in combined for k in ( ' hermes ' , ' codex ' , ' ai ' ) ) :
2026-04-23 16:45:34 +00:00
return f ' { topic_name } AI productivity '
return f ' { topic_name } discussion '
if any ( k in combined for k in ( ' title ' , ' session title ' ) ) and any ( k in combined for k in ( ' summary ' , ' summar ' , ' short title ' ) ) :
if any ( k in combined for k in ( ' test ' , ' ok ' , ' reply ok ' ) ) :
return ' Session title auto-summary test '
return ' Session title auto-summary '
if any ( k in combined for k in ( ' clarify ' , ' clarification ' ) ) and any ( k in combined for k in ( ' dialog ' , ' card ' ) ) :
return ' Clarify dialog card '
if any ( k in combined for k in ( ' issue ' , ' github ' , ' pr ' ) ) and any ( k in combined for k in ( ' triage ' , ' bug ' , ' review ' ) ) :
2026-04-15 14:50:59 +00:00
return ' GitHub Issue Triage '
2026-04-23 16:45:34 +00:00
head = re . split ( r ' [.!? \ n] ' , user_text ) [ 0 ] . strip ( )
2026-04-15 14:50:59 +00:00
if not head :
return None
stop_en = {
' the ' , ' this ' , ' that ' , ' with ' , ' from ' , ' into ' , ' just ' , ' reply ' , ' please ' ,
' need ' , ' needs ' , ' want ' , ' wants ' , ' user ' , ' assistant ' , ' could ' , ' would ' ,
' should ' , ' about ' , ' there ' , ' here ' , ' test ' , ' testing ' , ' title ' , ' summary ' ,
}
2026-05-05 10:48:40 +00:00
# Unicode-aware Latin tokenization: keep the old "no leading underscore"
# and non-Latin placeholder behavior while allowing letters such as ä/ö/ü/ß.
# The previous ASCII-only pattern turned "führe" into "f" + "hre"; the short
# "f" was filtered and the broken "hre" became part of the title.
latin_word = r ' A-Za-z0-9À-ÖØ-öø-ÿ '
tokens = re . findall ( rf ' [ { latin_word } ][ { latin_word } _./+-]* ' , head )
2026-04-15 14:50:59 +00:00
if not tokens :
2026-04-23 16:45:34 +00:00
return ' Conversation topic '
2026-04-15 14:50:59 +00:00
picked = [ ]
for tok in tokens :
lower_tok = tok . lower ( )
2026-04-23 16:45:34 +00:00
if lower_tok in stop_en or len ( lower_tok ) < 3 :
continue
2026-04-15 14:50:59 +00:00
if tok not in picked :
picked . append ( tok )
if len ( picked ) > = 4 :
break
if picked :
return ' ' . join ( picked ) [ : 60 ]
2026-04-23 16:45:34 +00:00
return ' Conversation topic '
2026-04-15 14:50:59 +00:00
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
def _is_generic_fallback_title ( title : str ) - > bool :
""" Return True for low-information fallback labels that should not be persisted. """
return str ( title or ' ' ) . strip ( ) . lower ( ) in { ' conversation topic ' }
2026-04-15 14:50:59 +00:00
def _run_background_title_update ( session_id : str , user_text : str , assistant_text : str , placeholder_title : str , put_event , agent = None ) :
""" Generate and publish a better title after `done`, then end the stream. """
try :
try :
s = get_session ( session_id )
except KeyError :
_put_title_status ( put_event , session_id , ' skipped ' , ' missing_session ' )
return
# Allow self-heal when a previously generated title leaked thinking text.
_invalid_existing = _looks_invalid_generated_title ( s . title )
if getattr ( s , ' llm_title_generated ' , False ) and not _invalid_existing :
_put_title_status ( put_event , session_id , ' skipped ' , ' already_generated ' , str ( s . title or ' ' ) )
return
current = str ( s . title or ' ' ) . strip ( )
2026-06-05 20:34:54 +00:00
if session_has_manual_title ( s ) :
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , current )
return
2026-04-15 14:50:59 +00:00
still_auto = (
current == placeholder_title
or current in ( ' Untitled ' , ' New Chat ' , ' ' )
or _is_provisional_title ( current , s . messages )
or _invalid_existing
)
if not still_auto :
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , current )
return
2026-05-15 10:14:09 +00:00
from api import profiles as profiles_api
with profiles_api . profile_env_for_background_worker ( s , " background title " , logger_override = logger ) :
2026-05-15 09:02:42 +00:00
aux_title_configured = _aux_title_configured ( )
if agent and not aux_title_configured :
2026-04-24 01:07:02 +00:00
next_title , llm_status , raw_preview = _generate_llm_session_title_for_agent ( agent , user_text , assistant_text )
2026-05-15 09:02:42 +00:00
if not next_title and llm_status in ( ' llm_error ' , ' llm_invalid ' ) :
next_title , llm_status , raw_preview = _generate_llm_session_title_via_aux ( user_text , assistant_text , agent = agent , use_agent_model = True )
else :
next_title , llm_status , raw_preview = _generate_llm_session_title_via_aux ( user_text , assistant_text )
if not next_title and agent and llm_status in ( ' llm_error_aux ' , ' llm_invalid_aux ' ) :
next_title , llm_status , raw_preview = _generate_llm_session_title_for_agent ( agent , user_text , assistant_text )
source = llm_status
if not next_title :
fallback_title = _fallback_title_from_exchange ( user_text , assistant_text )
if fallback_title and not _is_generic_fallback_title ( fallback_title ) :
logger . debug ( " Using local fallback for session title generation " )
next_title = fallback_title
source = ' fallback '
elif fallback_title :
logger . debug ( " Skipping generic local fallback for session title generation: %r " , fallback_title )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
fallback_reason = (
f ' local_summary: { llm_status } '
if source == ' fallback ' and llm_status
else ' local_summary '
)
2026-04-23 21:25:43 +00:00
wrote_title = False
effective_title = current
if next_title :
with _get_session_agent_lock ( session_id ) :
with LOCK :
2026-05-30 16:13:46 +00:00
cached_session = SESSIONS . get ( session_id )
if cached_session is not None and getattr ( cached_session , ' session_id ' , None ) == session_id :
s = cached_session
2026-04-23 21:25:43 +00:00
effective_title = str ( s . title or ' ' ) . strip ( )
2026-06-05 20:34:54 +00:00
manual_title = session_has_manual_title ( s )
2026-04-23 21:25:43 +00:00
invalid_existing_now = _looks_invalid_generated_title ( s . title )
still_auto = (
effective_title == placeholder_title
or effective_title in ( ' Untitled ' , ' New Chat ' , ' ' )
or _is_provisional_title ( effective_title , s . messages )
or invalid_existing_now
)
2026-06-05 20:34:54 +00:00
if manual_title or not still_auto :
2026-04-23 21:25:43 +00:00
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , effective_title )
return
if next_title != effective_title :
s . title = next_title
2026-06-05 20:34:54 +00:00
mark_session_title_generated ( s )
2026-04-23 21:25:43 +00:00
# Keep chronological ordering stable in the sidebar.
s . save ( touch_updated_at = False )
effective_title = s . title
wrote_title = True
if wrote_title :
2026-04-15 14:50:59 +00:00
if source == ' fallback ' :
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
_put_title_status ( put_event , session_id , source , fallback_reason , effective_title , raw_preview )
2026-04-15 14:50:59 +00:00
else :
2026-04-23 21:25:43 +00:00
_put_title_status ( put_event , session_id , source , llm_status , effective_title , raw_preview )
put_event ( ' title ' , { ' session_id ' : session_id , ' title ' : effective_title } )
2026-04-15 14:50:59 +00:00
else :
2026-04-23 21:25:43 +00:00
_put_title_status ( put_event , session_id , ' skipped ' , source or ' unchanged ' , effective_title , raw_preview )
2026-04-15 14:50:59 +00:00
finally :
put_event ( ' stream_end ' , { ' session_id ' : session_id } )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
def _run_background_title_refresh ( session_id : str , user_text : str , assistant_text : str , current_title : str , put_event , agent = None ) :
""" Refresh an existing LLM-generated title using the latest exchange text.
Unlike _run_background_title_update , this does NOT guard on
llm_title_generated — it assumes the title was already LLM - generated
and the session has progressed enough to warrant a refresh .
It does NOT emit stream_end ( the caller already did ) .
"""
try :
try :
s = get_session ( session_id )
except KeyError :
return
# Safety: skip if user manually renamed since the check
effective = str ( s . title or ' ' ) . strip ( )
2026-06-05 20:34:54 +00:00
if session_has_manual_title ( s ) :
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , effective )
return
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
if effective != current_title :
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , effective )
return
if not effective or effective in ( ' Untitled ' , ' New Chat ' ) :
return
2026-05-15 10:14:09 +00:00
from api import profiles as profiles_api
with profiles_api . profile_env_for_background_worker ( s , " background title " , logger_override = logger ) :
2026-05-15 09:02:42 +00:00
aux_title_configured = _aux_title_configured ( )
if agent and not aux_title_configured :
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
next_title , llm_status , raw_preview = _generate_llm_session_title_for_agent ( agent , user_text , assistant_text )
2026-05-15 09:02:42 +00:00
if not next_title and llm_status in ( ' llm_error ' , ' llm_invalid ' ) :
next_title , llm_status , raw_preview = _generate_llm_session_title_via_aux ( user_text , assistant_text , agent = agent , use_agent_model = True )
else :
next_title , llm_status , raw_preview = _generate_llm_session_title_via_aux ( user_text , assistant_text )
if not next_title and agent and llm_status in ( ' llm_error_aux ' , ' llm_invalid_aux ' ) :
next_title , llm_status , raw_preview = _generate_llm_session_title_for_agent ( agent , user_text , assistant_text )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
if not next_title :
_put_title_status ( put_event , session_id , ' refresh_skipped ' , llm_status or ' empty ' , effective , raw_preview )
return
# Skip if the new title is essentially the same (after normalization)
normalized_current = re . sub ( r ' \ s+ ' , ' ' , effective ) . strip ( ) . lower ( )
normalized_new = re . sub ( r ' \ s+ ' , ' ' , next_title ) . strip ( ) . lower ( )
if normalized_current == normalized_new :
_put_title_status ( put_event , session_id , ' refresh_skipped ' , ' same_title ' , effective , raw_preview )
return
with _get_session_agent_lock ( session_id ) :
with LOCK :
2026-05-30 16:13:46 +00:00
cached_session = SESSIONS . get ( session_id )
if cached_session is not None and getattr ( cached_session , ' session_id ' , None ) == session_id :
s = cached_session
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
# Re-check: user may have renamed while we were generating
2026-06-05 20:34:54 +00:00
if session_has_manual_title ( s ) or str ( s . title or ' ' ) . strip ( ) != current_title :
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
_put_title_status ( put_event , session_id , ' skipped ' , ' manual_title ' , str ( s . title or ' ' ) . strip ( ) )
return
s . title = next_title
2026-06-05 20:34:54 +00:00
mark_session_title_generated ( s )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
effective_title = s . title
2026-05-05 10:48:40 +00:00
# Session.save() calls _write_session_index(), which acquires LOCK.
# Keep the per-session agent lock for mutation serialization, but
# release the global session LOCK before persisting to avoid a
# self-deadlock in the background title-refresh thread.
s . save ( touch_updated_at = False )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
_put_title_status ( put_event , session_id , ' refreshed ' , llm_status , effective_title , raw_preview )
put_event ( ' title ' , { ' session_id ' : session_id , ' title ' : effective_title } )
logger . info ( " Adaptive title refresh: session= %s new_title= %r " , session_id , effective_title )
except Exception :
logger . debug ( " Background title refresh failed for session %s " , session_id , exc_info = True )
2026-06-01 21:22:16 +00:00
def generate_session_title_for_session ( session , * , prefer_latest : bool = False , agent = None ) - > tuple [ Optional [ str ] , str , str ] :
""" Generate a session title on demand from persisted conversation messages.
This helper powers explicit UI title - regeneration controls . It intentionally
does not inspect or mutate ` ` llm_title_generated ` ` ; callers decide whether
replacing the current title is allowed , then persist the returned title .
"""
messages = getattr ( session , ' messages ' , None ) or [ ]
if prefer_latest :
user_text , assistant_text = _latest_exchange_snippets ( messages )
else :
user_text , assistant_text = _first_exchange_snippets ( messages )
if not user_text :
return None , ' empty_user_message ' , ' '
from api import profiles as profiles_api
with profiles_api . profile_env_for_background_worker ( session , " manual title regeneration " , logger_override = logger ) :
next_title , llm_status , raw_preview = _generate_llm_session_title_via_aux ( user_text , assistant_text , agent = agent )
if next_title :
return next_title , llm_status , raw_preview
fallback_title = _fallback_title_from_exchange ( user_text , assistant_text )
if fallback_title and not _is_generic_fallback_title ( fallback_title ) :
reason = f ' local_summary: { llm_status } ' if llm_status else ' local_summary '
return fallback_title , reason , raw_preview
return None , llm_status or ' empty_title ' , raw_preview
2026-05-15 03:19:08 +00:00
def _preserve_pre_compression_snapshot ( s , old_sid : str ) - > None :
""" Persist old_sid as a read-only pre-compression snapshot.
Context compression rotates the active WebUI session id from old_sid to the
agent ' s new continuation id. The old JSON must remain on disk for lineage
traversal , but it should not continue to appear as an active sidebar row .
"""
old_path = SESSION_DIR / f ' { old_sid } .json '
if not old_path . exists ( ) :
return
try :
existing_text = old_path . read_text ( encoding = ' utf-8 ' )
try :
existing = json . loads ( existing_text )
existing_msgs = len ( existing . get ( ' messages ' ) or [ ] )
existing_snapshot = bool ( existing . get ( ' pre_compression_snapshot ' ) )
except ( json . JSONDecodeError , ValueError ) :
2026-05-15 09:31:37 +00:00
# Treat corrupt/malformed old JSON as missing history and rewrite it
# from the in-memory pre-compression messages below. That is safer
# than leaving an unreadable recovery snapshot behind.
2026-05-15 03:19:08 +00:00
existing_msgs = - 1
existing_snapshot = False
if len ( s . messages ) > existing_msgs :
2026-05-15 09:31:37 +00:00
# In-memory messages are newer than the file; save the full old
2026-05-15 03:19:08 +00:00
# snapshot from the current session object while preserving its
# pre-existing parent_session_id lineage.
saved_sid = s . session_id
saved_snapshot = bool ( getattr ( s , ' pre_compression_snapshot ' , False ) )
2026-05-30 09:45:24 +00:00
saved_pinned = bool ( getattr ( s , ' pinned ' , False ) )
2026-05-15 03:19:08 +00:00
s . session_id = old_sid
s . pre_compression_snapshot = True
2026-05-30 09:45:24 +00:00
s . pinned = False
2026-05-15 15:06:35 +00:00
# Stage-359 / PR #2295: clear runtime stream-state fields on the
# archived snapshot so the sidebar does not reopen the parent as
# a permanently-running session while the child already holds the
# completed answer. The continuation session's live state is
# restored from saved_* locals in the finally block.
saved_active_stream_id = getattr ( s , ' active_stream_id ' , None )
saved_pending_user_message = getattr ( s , ' pending_user_message ' , None )
saved_pending_attachments = list ( getattr ( s , ' pending_attachments ' , [ ] ) or [ ] )
saved_pending_started_at = getattr ( s , ' pending_started_at ' , None )
2026-06-18 22:21:10 +00:00
saved_pending_user_source = getattr ( s , ' pending_user_source ' , None )
2026-05-15 15:06:35 +00:00
s . active_stream_id = None
s . pending_user_message = None
s . pending_attachments = [ ]
s . pending_started_at = None
2026-06-18 22:21:10 +00:00
s . pending_user_source = None
2026-05-15 03:19:08 +00:00
try :
2026-05-15 15:06:35 +00:00
# skip_index=False so the snapshot appears in _index.json with
# the pre_compression_snapshot marker. The sidebar projection
# (#2285) reads that marker to hide the snapshot from active
# rows while keeping the JSON discoverable for lineage traversal.
2026-05-15 03:19:08 +00:00
s . save ( touch_updated_at = False , skip_index = False )
logger . info (
" Preserved pre-compression session %s ( %d messages) to disk " ,
old_sid , len ( s . messages ) ,
)
finally :
s . session_id = saved_sid
s . pre_compression_snapshot = saved_snapshot
2026-05-30 09:45:24 +00:00
s . pinned = saved_pinned
2026-05-15 15:06:35 +00:00
s . active_stream_id = saved_active_stream_id
s . pending_user_message = saved_pending_user_message
s . pending_attachments = saved_pending_attachments
s . pending_started_at = saved_pending_started_at
2026-06-18 22:21:10 +00:00
s . pending_user_source = saved_pending_user_source
2026-05-15 03:19:08 +00:00
return
# Existing file is already at least as complete as memory; stamp only
# the snapshot marker so index/sidebar projection can hide it without
# rewriting a shorter messages array over a fuller transcript.
from api . models import Session
snapshot = Session . load ( old_sid )
if snapshot :
snapshot . pre_compression_snapshot = True
2026-05-30 09:45:24 +00:00
snapshot . pinned = False
2026-05-15 15:27:24 +00:00
# Stage-359 Opus SHOULD-FIX: clear runtime fields on the loaded
# snapshot too. If the disk snapshot was last persisted while the
# parent was live, it could carry a stale active_stream_id /
# pending_* over to disk. The sidebar projection filters snapshot
# rows so this is latent today, but the contract should match the
# primary branch above so future readers can trust snapshot files
# to never contain live runtime state.
snapshot . active_stream_id = None
snapshot . pending_user_message = None
snapshot . pending_attachments = [ ]
snapshot . pending_started_at = None
2026-06-19 01:05:01 +00:00
snapshot . pending_user_source = None
2026-05-15 03:19:08 +00:00
snapshot . save ( touch_updated_at = False , skip_index = False )
logger . info (
" Marked pre-compression session %s as sidebar-hidden snapshot " ,
old_sid ,
)
except OSError :
logger . debug ( " Could not read old session file before preservation " )
except Exception :
logger . debug ( " Failed to preserve pre-compression session file " , exc_info = True )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
def _maybe_schedule_title_refresh ( session , put_event , agent ) :
""" Check if the session is due for an adaptive title refresh and schedule it. """
refresh_interval = _get_title_refresh_interval ( )
if refresh_interval < = 0 :
return
current_title = str ( session . title or ' ' ) . strip ( )
if not current_title or current_title in ( ' Untitled ' , ' New Chat ' ) :
return
2026-06-05 20:34:54 +00:00
if session_has_manual_title ( session ) :
return
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
if not getattr ( session , ' llm_title_generated ' , False ) :
return
exchange_count = _count_exchanges ( session . messages )
if exchange_count < = 0 or exchange_count % refresh_interval != 0 :
return
last_u , last_a = _latest_exchange_snippets ( session . messages )
if not last_u and not last_a :
return
threading . Thread (
target = _run_background_title_refresh ,
args = ( session . session_id , last_u , last_a , current_title , put_event , agent ) ,
daemon = True ,
) . start ( )
2026-05-16 10:54:28 +00:00
def _strip_native_image_parts_from_content ( content ) :
""" Return provider-safe content with native image parts removed.
Text - only provider endpoints ( for example DeepSeek / OpenAI - compatible text
models ) reject historical OpenAI - style ` ` image_url ` ` parts before the agent
can recover . When WebUI is configured for text - mode image handling , preserve
textual content from mixed content arrays and drop only the native image
blocks from replayed history .
"""
if not isinstance ( content , list ) :
return content
clean_parts = [ ]
for part in content :
if not isinstance ( part , dict ) :
continue
if part . get ( ' type ' ) == ' image_url ' or ' image_url ' in part :
continue
clean_parts . append ( copy . deepcopy ( part ) )
if not clean_parts :
return ' '
if len ( clean_parts ) == 1 and clean_parts [ 0 ] . get ( ' type ' ) == ' text ' :
return str ( clean_parts [ 0 ] . get ( ' text ' ) or ' ' )
return clean_parts
2026-05-18 16:33:06 +00:00
def _content_has_reasoning_only_parts ( content ) - > bool :
if not isinstance ( content , list ) or not content :
return False
saw_reasoning = False
for part in content :
if not isinstance ( part , dict ) :
continue
part_type = part . get ( ' type ' )
if part_type in { ' thinking ' , ' reasoning ' } :
text = part . get ( ' thinking ' ) or part . get ( ' reasoning ' ) or part . get ( ' text ' ) or ' '
if str ( text ) . strip ( ) :
saw_reasoning = True
continue
if part_type == ' text ' and str ( part . get ( ' text ' ) or part . get ( ' content ' ) or ' ' ) . strip ( ) :
return False
if part_type not in { ' text ' , ' thinking ' , ' reasoning ' } :
return False
return saw_reasoning
def _is_reasoning_only_assistant_message ( msg ) - > bool :
""" Return True for display-only assistant Thinking entries.
These entries keep partial Thinking cards visible after reload / cancel , but
they are not API - safe history : providers only see a blank assistant turn .
Visible assistant replies that also carry reasoning metadata are kept .
"""
if not isinstance ( msg , dict ) or msg . get ( ' role ' ) != ' assistant ' :
return False
if msg . get ( ' tool_calls ' ) :
return False
content = msg . get ( ' content ' , ' ' )
if _message_text ( content ) . strip ( ) :
return False
if str ( msg . get ( ' reasoning ' ) or msg . get ( ' reasoning_content ' ) or ' ' ) . strip ( ) :
return True
return _content_has_reasoning_only_parts ( content )
2026-05-16 10:54:28 +00:00
def _sanitize_messages_for_api ( messages , * , cfg : dict = None ) :
2026-04-04 05:13:12 +00:00
""" Return a deep copy of messages with only API-safe fields.
The webui stores extra metadata on messages ( attachments , timestamp , _ts )
for display purposes . Some providers ( e . g . Z . AI / GLM ) reject unknown fields
instead of ignoring them , causing HTTP 400 errors on subsequent messages .
fix: strip orphaned tool messages before sending history to API (fixes #534)
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
by a preceding assistant message.
Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.
Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
2026-04-15 16:57:31 +00:00
Also strips orphaned tool - role messages whose tool_call_id cannot be linked
to a preceding assistant message with tool_calls . Strictly - conformant providers
( Mercury - 2 / Inception , newer OpenAI models ) reject histories containing dangling
tool results with a 400 error : " Message has tool role, but there was no previous
assistant message with a tool call . "
2026-05-16 10:54:28 +00:00
If ` ` agent . image_input_mode ` ` resolves to ` ` text ` ` , native historical
` ` image_url ` ` content parts are stripped too . Current - turn uploads already
respect text mode in ` ` _build_native_multimodal_message ` ` ; this closes the
remaining replay gap where an older native image in the saved transcript kept
causing 400 s on every later text - only turn ( #2297).
2026-04-04 05:13:12 +00:00
"""
2026-05-16 10:54:28 +00:00
strip_native_images = cfg is not None and _resolve_image_input_mode ( cfg ) == " text "
fix: strip orphaned tool messages before sending history to API (fixes #534)
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
by a preceding assistant message.
Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.
Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
2026-04-15 16:57:31 +00:00
# First pass: collect all tool_call_ids declared by assistant messages.
# Handles both OpenAI ('id') and Anthropic ('call_id') field names.
valid_tool_call_ids : set = set ( )
for msg in messages :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' role ' ) == ' assistant ' :
for tc in msg . get ( ' tool_calls ' ) or [ ] :
if isinstance ( tc , dict ) :
tid = tc . get ( ' id ' ) or tc . get ( ' call_id ' ) or ' '
if tid :
valid_tool_call_ids . add ( tid )
# Second pass: build the sanitized list, dropping orphaned tool messages.
2026-04-04 05:13:12 +00:00
clean = [ ]
2026-06-19 05:21:13 +00:00
for msg in messages :
2026-04-04 05:13:12 +00:00
if not isinstance ( msg , dict ) :
continue
2026-05-18 16:33:06 +00:00
# Skip display-only Thinking entries. They are visible transcript
# metadata, not provider-facing assistant turns.
if _is_reasoning_only_assistant_message ( msg ) :
continue
2026-04-20 22:48:19 +00:00
# Skip persisted error markers — never send them to the LLM as prior context.
if msg . get ( ' _error ' ) :
continue
2026-05-21 17:56:46 +00:00
# Skip _partial markers with no visible content. Partial messages that
# carry actual text (e.g. "Python is a high-level…") are kept so the
# model can continue from the cut-off point (#893). But empty partials
# (reasoning-only or tool-only cancellations where thinking markup was
# stripped) have nothing for the model to continue from and cause
# API 400 errors on strict providers (empty assistant content).
if msg . get ( ' _partial ' ) and not str ( msg . get ( ' content ' ) or ' ' ) . strip ( ) :
continue
2026-06-19 05:06:33 +00:00
# Note: _recovered user messages are NOT skipped here — they may need
# to be retained to preserve role alternation when a kept assistant
# follows. The _recovered skip happens in a final pass after orphaned
# tool_calls are stripped, so the anchor check is exact (#4283).
# Temporarily mark _recovered users so the final pass can find them.
is_recovered = msg . get ( ' _recovered ' ) and msg . get ( ' role ' ) == ' user '
fix: strip orphaned tool messages before sending history to API (fixes #534)
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
by a preceding assistant message.
Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.
Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
2026-04-15 16:57:31 +00:00
role = msg . get ( ' role ' )
if role == ' tool ' :
tid = msg . get ( ' tool_call_id ' ) or ' '
if not tid or tid not in valid_tool_call_ids :
# Orphaned tool result — skip to avoid 400 from strict providers.
continue
2026-04-04 05:13:12 +00:00
sanitized = { k : v for k , v in msg . items ( ) if k in _API_SAFE_MSG_KEYS }
2026-06-19 05:06:33 +00:00
if is_recovered :
sanitized [ ' _recovered ' ] = True # temporary marker — stripped before return
2026-05-16 10:54:28 +00:00
if strip_native_images and ' content ' in sanitized :
sanitized [ ' content ' ] = _strip_native_image_parts_from_content ( sanitized . get ( ' content ' ) )
2026-04-04 05:13:12 +00:00
if sanitized . get ( ' role ' ) :
clean . append ( sanitized )
2026-06-04 22:04:45 +00:00
# Third pass: strip orphaned tool_calls from assistant messages — calls whose id
# has no matching tool-role response in the clean list. Strict providers (DeepSeek,
# newer OpenAI) reject with 400 when an assistant message references a tool call that
# was never answered (e.g. session aborted before results flushed).
answered_ids : set = set ( )
for msg in clean :
if msg . get ( ' role ' ) == ' tool ' :
tid = msg . get ( ' tool_call_id ' ) or ' '
if tid :
answered_ids . add ( tid )
filtered_clean = [ ]
for msg in clean :
if msg . get ( ' role ' ) == ' assistant ' and msg . get ( ' tool_calls ' ) :
kept = [
tc for tc in msg [ ' tool_calls ' ]
if isinstance ( tc , dict ) and
( tc . get ( ' id ' ) or tc . get ( ' call_id ' ) or ' ' ) in answered_ids
]
if not kept :
# All calls orphaned: drop tool_calls key; if no content, drop message.
msg = { k : v for k , v in msg . items ( ) if k != ' tool_calls ' }
if not str ( msg . get ( ' content ' ) or ' ' ) . strip ( ) :
continue
else :
msg = dict ( msg , tool_calls = kept )
filtered_clean . append ( msg )
2026-06-19 05:06:33 +00:00
# Fourth pass: drop _recovered user messages unless removing one would fuse
# two same-role neighbours. Operating on filtered_clean (post orphaned-tool/
# tool_calls stripping) means the neighbour check is exact (#4283). The
# decision uses the ACTUAL kept sequence: the previously-kept message's role
# (`final[-1]`) and the next surviving message's role. A _recovered user is
# kept ONLY when it separates two assistants (prev kept == assistant AND next
# surviving == assistant) — i.e. it is an answered turn whose removal would
# leave `assistant, assistant` adjacency. In every other case dropping it is
# safe and correct: it would either leave a clean `user, assistant` pair, or
# (if next is a user) it is a stale unanswered prompt that must not replay.
# Deciding only on "an assistant follows" (ignoring the prev kept role) is the
# bug that re-introduced `user, _recovered user, assistant` → adjacent users
# → strict-provider 400 once the anchoring assistant's predecessor was a user.
final = [ ]
for i , msg in enumerate ( filtered_clean ) :
if msg . get ( ' _recovered ' ) and msg . get ( ' role ' ) == ' user ' :
prev_role = final [ - 1 ] . get ( ' role ' ) if final else None
next_role = None
for j in range ( i + 1 , len ( filtered_clean ) ) :
next_role = filtered_clean [ j ] . get ( ' role ' )
break
# Keep only if this recovered user actually separates two assistants.
if not ( prev_role == ' assistant ' and next_role == ' assistant ' ) :
continue # drop — fusing the neighbours is clean, or it's a stale prompt
# Keep but strip the temporary marker
msg = { k : v for k , v in msg . items ( ) if k != ' _recovered ' }
final . append ( msg )
return final
2026-04-04 05:13:12 +00:00
2026-03-31 03:40:19 +00:00
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
def _api_safe_message_positions ( messages ) :
""" Return [(original_index, sanitized_message)] for API-safe messages. """
valid_tool_call_ids : set = set ( )
for msg in messages :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' role ' ) == ' assistant ' :
for tc in msg . get ( ' tool_calls ' ) or [ ] :
if isinstance ( tc , dict ) :
tid = tc . get ( ' id ' ) or tc . get ( ' call_id ' ) or ' '
if tid :
valid_tool_call_ids . add ( tid )
out = [ ]
for idx , msg in enumerate ( messages ) :
if not isinstance ( msg , dict ) :
continue
2026-05-18 16:33:06 +00:00
if _is_reasoning_only_assistant_message ( msg ) :
continue
2026-05-21 17:56:46 +00:00
if msg . get ( ' _error ' ) :
continue
if msg . get ( ' _partial ' ) and not str ( msg . get ( ' content ' ) or ' ' ) . strip ( ) :
continue
2026-06-19 05:06:33 +00:00
# Note: _recovered user messages are NOT skipped here — deferred to
# a final pass after orphaned tool_calls stripping (#4283).
is_recovered = msg . get ( ' _recovered ' ) and msg . get ( ' role ' ) == ' user '
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
role = msg . get ( ' role ' )
if role == ' tool ' :
tid = msg . get ( ' tool_call_id ' ) or ' '
if not tid or tid not in valid_tool_call_ids :
continue
sanitized = { k : v for k , v in msg . items ( ) if k in _API_SAFE_MSG_KEYS }
2026-06-19 05:06:33 +00:00
if is_recovered :
sanitized [ ' _recovered ' ] = True # temporary marker — stripped before return
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
if sanitized . get ( ' role ' ) :
out . append ( ( idx , sanitized ) )
2026-06-04 22:04:45 +00:00
# Third pass: strip orphaned tool_calls from assistant messages (mirrors
# _sanitize_messages_for_api pass 3).
answered_ids : set = set ( )
for _idx , msg in out :
if msg . get ( ' role ' ) == ' tool ' :
tid = msg . get ( ' tool_call_id ' ) or ' '
if tid :
answered_ids . add ( tid )
filtered_out = [ ]
for idx , msg in out :
if msg . get ( ' role ' ) == ' assistant ' and msg . get ( ' tool_calls ' ) :
kept = [
tc for tc in msg [ ' tool_calls ' ]
if isinstance ( tc , dict ) and
( tc . get ( ' id ' ) or tc . get ( ' call_id ' ) or ' ' ) in answered_ids
]
if not kept :
msg = { k : v for k , v in msg . items ( ) if k != ' tool_calls ' }
if not str ( msg . get ( ' content ' ) or ' ' ) . strip ( ) :
continue
else :
msg = dict ( msg , tool_calls = kept )
filtered_out . append ( ( idx , msg ) )
2026-06-19 05:06:33 +00:00
# Fourth pass: drop _recovered user messages unless removing one would fuse
# two same-role neighbours — mirrors _sanitize_messages_for_api pass 4 (#4283).
# Decide on the ACTUAL kept sequence: prev kept role (final_out[-1]) + next
# surviving role. Keep ONLY when it separates two assistants; otherwise drop.
final_out = [ ]
for i , ( idx , msg ) in enumerate ( filtered_out ) :
if msg . get ( ' _recovered ' ) and msg . get ( ' role ' ) == ' user ' :
prev_role = final_out [ - 1 ] [ 1 ] . get ( ' role ' ) if final_out else None
next_role = None
for j in range ( i + 1 , len ( filtered_out ) ) :
next_role = filtered_out [ j ] [ 1 ] . get ( ' role ' )
break
if not ( prev_role == ' assistant ' and next_role == ' assistant ' ) :
continue
msg = { k : v for k , v in msg . items ( ) if k != ' _recovered ' }
final_out . append ( ( idx , msg ) )
return final_out
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
2026-05-21 17:56:43 +00:00
def _deduplicate_context_messages ( messages ) :
""" Remove duplicate messages from context by identity, keeping first occurrence.
Prevents the agent from seeing the same message twice in conversation_history
when result_messages contain duplicates that weren ' t caught by display-merge.
"""
if not messages :
return messages
seen = set ( )
deduped = [ ]
for msg in messages :
key = _message_identity ( msg )
if key is not None and key in seen :
continue
if key is not None :
seen . add ( key )
deduped . append ( msg )
return deduped
2026-06-06 19:12:37 +00:00
def _prune_context_tool_results_after_compression ( agent , context_messages ) :
""" Run the active compressor ' s cheap tool-result pruning on model context.
Auto - compression can happen mid - turn and then the agent may run more tools
before producing the final answer . Those completed tail tool results are
model - facing context , but they were produced after the compression pass and
therefore did not go through the compressor ' s tool-output pruning. Apply the
same cheap pruning once more after a confirmed compression event . This keeps
the visible transcript untouched while preventing the next turn from seeing
raw post - compression tool dumps .
"""
if not context_messages :
return context_messages
compressor = getattr ( agent , ' context_compressor ' , None )
prune = getattr ( compressor , ' _prune_old_tool_results ' , None )
if not callable ( prune ) :
return context_messages
try :
pruned_messages , pruned_count = prune (
copy . deepcopy ( context_messages ) ,
protect_tail_count = getattr ( compressor , ' protect_last_n ' , 20 ) ,
protect_tail_tokens = getattr ( compressor , ' tail_token_budget ' , None ) ,
)
except Exception :
logger . debug ( " post-compression context tool-result pruning failed " , exc_info = True )
return context_messages
if not pruned_count :
return context_messages
return _deduplicate_context_messages ( pruned_messages )
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
def _restore_reasoning_metadata ( previous_messages , updated_messages ) :
2026-04-20 07:53:19 +00:00
""" Carry forward display-only metadata lost during API-safe history sanitization.
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
The provider - facing history strips WebUI - only fields like ` reasoning ` . When the
agent returns its new full message history , prior assistant messages come back
without that metadata unless we merge it back in by API - history position .
2026-04-20 07:53:19 +00:00
This also preserves existing timestamps for unchanged historical messages .
Without that , older turns that come back from the agent without ` _ts ` /
` timestamp ` can be re - stamped with the current time on every new assistant
response , making prior messages appear to " move " in time .
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
"""
if not previous_messages or not updated_messages :
return updated_messages
updated_messages = list ( updated_messages )
prev_safe = _api_safe_message_positions ( previous_messages )
def _safe_projection ( msg ) :
if not isinstance ( msg , dict ) :
return None
return { k : v for k , v in msg . items ( ) if k in _API_SAFE_MSG_KEYS and msg . get ( ' role ' ) }
safe_pos = 0
while safe_pos < len ( prev_safe ) :
prev_idx , _ = prev_safe [ safe_pos ]
prev_msg = previous_messages [ prev_idx ]
cur_msg = updated_messages [ safe_pos ] if safe_pos < len ( updated_messages ) else None
if isinstance ( prev_msg , dict ) and isinstance ( cur_msg , dict ) and _safe_projection ( prev_msg ) == _safe_projection ( cur_msg ) :
if prev_msg . get ( ' role ' ) == ' assistant ' and prev_msg . get ( ' reasoning ' ) and not cur_msg . get ( ' reasoning ' ) :
cur_msg [ ' reasoning ' ] = prev_msg [ ' reasoning ' ]
2026-04-20 07:53:19 +00:00
if prev_msg . get ( ' timestamp ' ) and not cur_msg . get ( ' timestamp ' ) :
cur_msg [ ' timestamp ' ] = prev_msg [ ' timestamp ' ]
elif prev_msg . get ( ' _ts ' ) and not cur_msg . get ( ' _ts ' ) and not cur_msg . get ( ' timestamp ' ) :
cur_msg [ ' _ts ' ] = prev_msg [ ' _ts ' ]
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
safe_pos + = 1
continue
safe_pos + = 1
2026-05-18 16:33:06 +00:00
return updated_messages
def _restore_display_reasoning_metadata ( previous_messages , updated_messages ) :
""" Restore display-only thinking rows for visible transcript persistence. """
updated_messages = _restore_reasoning_metadata ( previous_messages , updated_messages )
if not previous_messages or not updated_messages :
return updated_messages
prev_safe = _api_safe_message_positions ( previous_messages )
safe_indices = { idx for idx , _ in prev_safe }
inserted_reasoning_only = 0
for prev_idx , prev_msg in enumerate ( previous_messages ) :
2026-05-28 07:30:49 +00:00
if _is_empty_partial_activity_message ( prev_msg ) :
continue
2026-05-18 16:33:06 +00:00
if prev_idx in safe_indices or not _is_reasoning_only_assistant_message ( prev_msg ) :
continue
safe_pos = sum ( 1 for idx , _ in prev_safe if idx < prev_idx ) + inserted_reasoning_only
existing = updated_messages [ safe_pos ] if safe_pos < len ( updated_messages ) else None
if isinstance ( existing , dict ) and _is_reasoning_only_assistant_message ( existing ) :
continue
updated_messages . insert ( safe_pos , copy . deepcopy ( prev_msg ) )
inserted_reasoning_only + = 1
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
return updated_messages
2026-04-29 08:37:08 +00:00
def _session_context_messages ( session ) :
""" Return model-facing history without assuming it matches the UI transcript. """
context_messages = getattr ( session , ' context_messages ' , None )
if isinstance ( context_messages , list ) and context_messages :
return context_messages
return session . messages or [ ]
def _message_identity ( msg ) :
if not isinstance ( msg , dict ) :
return None
role = str ( msg . get ( ' role ' ) or ' ' )
content = msg . get ( ' content ' , ' ' )
text = _message_text ( content )
2026-05-08 10:14:13 +00:00
if role == ' user ' :
# WebUI sends the model a workspace-prefixed user_message while the
# visible optimistic bubble contains only the human text. Treat them as
# the same turn for merge/dedup purposes; otherwise compaction results
# render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk").
2026-05-08 16:29:22 +00:00
text = _strip_workspace_prefix ( text , include_legacy = True )
2026-04-29 08:37:08 +00:00
if not text and not msg . get ( ' tool_call_id ' ) and not msg . get ( ' tool_calls ' ) :
2026-05-21 17:56:46 +00:00
# Empty assistant messages (e.g. _partial markers with no visible
# content) previously returned None, making them invisible to the
# merge dedup in _merge_display_messages_after_agent_result. This
# caused exponential accumulation: each turn's merge copied ALL
# prior _partial messages because they had no identity to track.
# Now, _partial messages with empty text get a stable identity
# keyed on their role + _partial flag + reasoning/tool metadata,
# so the merge can dedup identical empty partials.
if msg . get ( ' _partial ' ) :
reasoning_key = " " . join ( str ( msg . get ( ' reasoning ' ) or ' ' ) . split ( ) ) [ : 200 ]
return (
role ,
' ' , # empty text
' ' , # no tool_call_id
' __partial__ ' + reasoning_key ,
)
2026-04-29 08:37:08 +00:00
return None
return (
role ,
" " . join ( str ( text or ' ' ) . split ( ) ) [ : 500 ] ,
str ( msg . get ( ' tool_call_id ' ) or ' ' ) ,
json . dumps ( msg . get ( ' tool_calls ' ) or [ ] , sort_keys = True , ensure_ascii = False ) ,
)
def _messages_have_prefix ( messages , prefix ) :
if len ( messages or [ ] ) < len ( prefix or [ ] ) :
return False
for idx , expected in enumerate ( prefix or [ ] ) :
if _message_identity ( ( messages or [ ] ) [ idx ] ) != _message_identity ( expected ) :
return False
return True
2026-05-20 21:15:54 +00:00
def _message_replay_key ( msg ) :
""" Return a stable comparison key for replay/overlap de-duplication. """
identity = _message_identity ( msg )
if identity is not None :
return identity
if not isinstance ( msg , dict ) :
return None
return (
str ( msg . get ( ' role ' ) or ' ' ) ,
_message_text ( msg . get ( ' content ' , ' ' ) ) ,
str ( msg . get ( ' tool_call_id ' ) or ' ' ) ,
json . dumps ( msg . get ( ' tool_calls ' ) or [ ] , sort_keys = True , ensure_ascii = False ) ,
)
def _strip_replayed_prefix ( existing_messages , candidates ) :
""" Drop a candidate prefix that is already the suffix of existing_messages.
Compression / continuation can replay the active tail from state . db after the
previous WebUI context / display already contains it . Prefix - only merge logic
then treats that replayed tail as a fresh delta and duplicates a whole turn .
Strip the largest exact suffix / prefix overlap before appending .
"""
existing_messages = list ( existing_messages or [ ] )
candidates = list ( candidates or [ ] )
max_overlap = min ( len ( existing_messages ) , len ( candidates ) )
for overlap in range ( max_overlap , 0 , - 1 ) :
left = [ _message_replay_key ( m ) for m in existing_messages [ - overlap : ] ]
right = [ _message_replay_key ( m ) for m in candidates [ : overlap ] ]
if left == right :
return candidates [ overlap : ]
return candidates
2026-05-21 05:42:19 +00:00
def _looks_like_replayed_session_arc_summary ( previous_msg , candidate_msg ) :
""" Return True for repeated LCM/session summaries with refreshed hints.
LCM summary cards can be re - injected with the same long recovered context
and a different tail such as an expand hint . Exact identity misses those ,
but appending both copies bloats every later model prompt .
"""
if not isinstance ( previous_msg , dict ) or not isinstance ( candidate_msg , dict ) :
return False
if previous_msg . get ( ' role ' ) != candidate_msg . get ( ' role ' ) :
return False
previous_text = " " . join ( _message_text ( previous_msg . get ( ' content ' , ' ' ) ) . split ( ) )
candidate_text = " " . join ( _message_text ( candidate_msg . get ( ' content ' , ' ' ) ) . split ( ) )
if len ( previous_text ) < 2000 or len ( candidate_text ) < 2000 :
return False
marker = ' [Session Arc Summary '
if not previous_text . startswith ( marker ) or not candidate_text . startswith ( marker ) :
return False
return previous_text [ : 1500 ] == candidate_text [ : 1500 ]
def _strip_replayed_context_items ( existing_messages , candidates ) :
""" Drop replayed non-adjacent context blocks before persisting context. """
existing_messages = list ( existing_messages or [ ] )
candidates = list ( candidates or [ ] )
if not existing_messages or not candidates :
return candidates
existing_keys = [ _message_replay_key ( m ) for m in existing_messages ]
candidate_keys = [ _message_replay_key ( m ) for m in candidates ]
existing_large = [ m for m in existing_messages if isinstance ( m , dict ) ]
cleaned = [ ]
idx = 0
min_block = 3
while idx < len ( candidates ) :
msg = candidates [ idx ]
if any ( _looks_like_replayed_session_arc_summary ( prev , msg ) for prev in existing_large ) :
idx + = 1
continue
best = 0
for start in range ( len ( existing_keys ) ) :
length = 0
while (
idx + length < len ( candidate_keys )
and start + length < len ( existing_keys )
and candidate_keys [ idx + length ] == existing_keys [ start + length ]
) :
length + = 1
if length > best :
best = length
if best > = min_block :
idx + = best
continue
cleaned . append ( msg )
idx + = 1
return cleaned
2026-06-16 17:03:37 +00:00
def _dedupe_replayed_context_messages ( previous_context , result_messages , msg_text = None ) :
2026-05-21 05:42:19 +00:00
""" Keep model context append-only without replayed blocks/summaries. """
2026-05-20 21:15:54 +00:00
previous_context = list ( previous_context or [ ] )
result_messages = list ( result_messages or [ ] )
if not previous_context or not result_messages :
return result_messages
2026-06-16 17:03:37 +00:00
previous_user_tail = _stale_user_tail_candidate ( _last_user_row ( previous_context ) )
2026-05-20 21:15:54 +00:00
if not _messages_have_prefix ( result_messages , previous_context ) :
2026-06-16 17:03:37 +00:00
# Agent-side role-sequence repair can replace the last prior user row
# with a repaired current-user row. In that shape the result no longer
# has `previous_context` as an exact prefix, but it should still be
# merged as: previous context + clean current turn + assistant/tool delta.
if (
msg_text
and len ( previous_context ) > = 1
and len ( result_messages ) > = len ( previous_context )
and _messages_have_prefix ( result_messages , previous_context [ : - 1 ] )
) :
boundary_idx = len ( previous_context ) - 1
boundary_row = result_messages [ boundary_idx ]
2026-06-17 02:20:53 +00:00
is_stale_merge = bool (
2026-06-16 17:03:37 +00:00
previous_user_tail
2026-06-17 02:20:53 +00:00
and _detect_stale_user_merge (
boundary_row ,
msg_text ,
previous_user_tail ,
previous_context = previous_context ,
)
2026-06-16 17:03:37 +00:00
)
if is_stale_merge or _looks_like_current_user_turn ( boundary_row , msg_text ) :
if is_stale_merge :
# Clean only the stale-merged boundary row; leave all prior
# history in previous_context untouched.
cleaned_boundary = copy . deepcopy ( boundary_row )
cleaned_boundary [ ' content ' ] = msg_text
candidates = [ cleaned_boundary ] + result_messages [ boundary_idx + 1 : ]
else :
candidates = result_messages [ boundary_idx : ]
candidates = _strip_replayed_prefix ( previous_context , candidates )
if candidates :
candidates = _strip_replayed_context_items ( previous_context , candidates )
return previous_context + candidates
2026-05-20 21:15:54 +00:00
return result_messages
candidates = result_messages [ len ( previous_context ) : ]
2026-06-16 17:03:37 +00:00
# Strip stale merges only from the new-turn candidate slice so that
# legitimate historical user rows in the already-committed previous_context
# prefix are never rewritten.
if msg_text and previous_user_tail :
candidates = _strip_stale_user_merge_from_messages (
candidates ,
msg_text ,
previous_user_tail ,
2026-06-17 02:20:53 +00:00
previous_context = previous_context ,
2026-06-16 17:03:37 +00:00
)
2026-05-21 05:42:19 +00:00
candidates = _strip_replayed_prefix ( previous_context , candidates )
2026-05-22 09:48:28 +00:00
if candidates :
candidates = _strip_replayed_context_items ( previous_context , candidates )
2026-05-21 05:42:19 +00:00
return previous_context + candidates
2026-06-16 17:03:37 +00:00
def _dedupe_replayed_active_context ( previous_context , result_messages , msg_text = None ) :
2026-05-21 05:42:19 +00:00
""" Keep model context append-only without re-appending a replayed tail. """
2026-06-16 17:03:37 +00:00
return _dedupe_replayed_context_messages ( previous_context , result_messages , msg_text )
2026-05-20 21:15:54 +00:00
2026-04-29 08:37:08 +00:00
def _is_context_compression_marker ( msg ) :
2026-05-23 18:31:07 +00:00
return is_context_compression_marker ( msg )
2026-04-29 08:37:08 +00:00
2026-06-08 18:35:39 +00:00
def _compact_summary_text ( raw_text : str | None ) - > str | None :
2026-05-10 08:44:09 +00:00
""" Normalize a text blob used in compression summary cards. """
if not isinstance ( raw_text , str ) :
return None
txt = raw_text . strip ( )
if not txt :
return None
2026-06-08 18:35:39 +00:00
return re . sub ( r " \ s+ " , " " , txt ) . strip ( )
2026-05-10 08:44:09 +00:00
def _compression_anchor_message_key ( message ) :
if not isinstance ( message , dict ) :
return None
role = str ( message . get ( ' role ' ) or ' ' )
if not role or role == ' tool ' :
return None
content = message . get ( ' content ' , ' ' )
text = _message_text ( content )
if len ( text ) > 160 :
text = text [ : 160 ]
ts = message . get ( ' _ts ' ) or message . get ( ' timestamp ' )
attachments = message . get ( ' attachments ' )
attach_count = len ( attachments ) if isinstance ( attachments , list ) else 0
if not text and not attach_count and not ts :
return None
return { ' role ' : role , ' ts ' : ts , ' text ' : text , ' attachments ' : attach_count }
def _compression_summary_from_messages ( messages ) :
for m in reversed ( messages or [ ] ) :
if not isinstance ( m , dict ) :
continue
if not _is_context_compression_marker ( m ) :
continue
text = _message_text ( m . get ( ' content ' ) )
if text :
return text
return None
2026-04-29 08:37:08 +00:00
def _find_current_user_turn ( messages , msg_text ) :
needle = " " . join ( str ( msg_text or ' ' ) . split ( ) )
2026-06-03 17:11:32 +00:00
last_strong_match = None # _looks_like_current_user_turn (high confidence)
last_weak_match = None # needle substring match (lower confidence)
2026-04-29 08:37:08 +00:00
fallback = None
for idx , msg in enumerate ( messages or [ ] ) :
if not isinstance ( msg , dict ) or msg . get ( ' role ' ) != ' user ' :
continue
fallback = idx
2026-05-12 14:38:29 +00:00
if _looks_like_current_user_turn ( msg , msg_text ) :
2026-06-03 17:11:32 +00:00
last_strong_match = idx
continue
2026-05-08 16:29:22 +00:00
text = " " . join (
_strip_workspace_prefix (
_message_text ( msg . get ( ' content ' , ' ' ) ) ,
include_legacy = True ,
) . split ( )
)
2026-04-29 08:37:08 +00:00
if needle and ( needle in text or text in needle ) :
2026-06-03 17:11:32 +00:00
last_weak_match = idx
# Return the LAST matching user turn. After context compression the agent's
# result_messages contain the full conversation history; if the user asked a
# similar question in an earlier turn, first-match would return that old
# index, causing the merge to replay the entire history from that point.
# Last-match anchors on the current turn instead.
#
# Prefer the last STRONG match (an exact `_looks_like_current_user_turn`
# hit) over the last WEAK substring match. The agent loop appends synthetic
# `role:"user"` continuation prompts (e.g. "Continue", empty-recovery nudges
# — see conversation_loop.py) AFTER the real user turn; those can weak-match
# `msg_text` and, if weak matches were allowed to win, would anchor the merge
# PAST the real turn and drop the assistant/tool output in between. The real
# current turn is the last strong match, so it must take priority.
if last_strong_match is not None :
return last_strong_match
if last_weak_match is not None :
return last_weak_match
2026-04-29 08:37:08 +00:00
return fallback
2026-05-04 21:05:49 +00:00
def _drop_checkpointed_current_user_from_context ( messages , msg_text ) :
""" Return model history without an eager-checkpointed current user turn. """
history = list ( messages or [ ] )
if not history :
return history
current_user_key = _message_identity ( { ' role ' : ' user ' , ' content ' : msg_text } )
if current_user_key and _message_identity ( history [ - 1 ] ) == current_user_key :
return history [ : - 1 ]
return history
2026-06-16 17:03:37 +00:00
def _strip_workspace_prefixes_for_compare ( text : str ) - > str :
""" Remove WebUI workspace sentinels anywhere before text comparison. """
value = _strip_workspace_prefix ( text , include_legacy = True )
for pattern in ( _WORKSPACE_PREFIX_ANY_RE , _LEGACY_WORKSPACE_PREFIX_ANY_RE ) :
value = pattern . sub ( ' ' , value )
return value . strip ( )
def _normalize_user_text ( text ) :
""" Collapse whitespace and strip workspace sentinels for tail comparisons. """
if not isinstance ( text , str ) :
return " "
return " " . join ( _strip_workspace_prefixes_for_compare ( text ) . split ( ) )
def _raw_message_text ( value ) - > str :
""" Extract text from a message content payload without stripping markup.
Used for the stale - user - merge detector so the literal boundary between
the prior tail and the current turn survives into the comparison . The
thinking - markup strip in ` ` _message_text ` ` collapses newlines , which
would defeat the boundary check .
"""
if isinstance ( value , list ) :
return ' ' . join (
str ( p . get ( ' text ' ) or p . get ( ' content ' ) or ' ' )
for p in value
if isinstance ( p , dict )
)
return str ( value or ' ' )
def _stale_user_tail_candidate ( msg ) :
""" Return normalized text if msg is a user row that could be a stale tail. """
if not isinstance ( msg , dict ) or msg . get ( ' role ' ) != ' user ' :
return None
raw = _raw_message_text ( msg . get ( ' content ' , ' ' ) )
if not raw . strip ( ) :
return None
return _normalize_user_text ( raw )
def _last_user_row ( messages ) :
""" Return the last user-role row in `messages`, or None. """
for msg in reversed ( list ( messages or [ ] ) ) :
if isinstance ( msg , dict ) and msg . get ( ' role ' ) == ' user ' :
return msg
return None
2026-06-17 02:20:53 +00:00
def _stale_prefix_matches_prior_user_context ( stale_prefix , stale_segments , previous_context ) :
""" Return True when a stale prefix is explainable by prior user context.
First - generation repair usually produces segments matching consecutive
prior user rows . Once a session is already contaminated , later repair can
replay a stable stale prefix from an older polluted row even after newer
clean user turns have moved the context tail forward . Handle both shapes
while still requiring all evidence to come from prior user - role rows .
"""
prior_rows = [
_stale_user_tail_candidate ( msg )
for msg in previous_context or [ ]
]
prior_rows = [ row for row in prior_rows if row ]
if not prior_rows :
return False
if stale_segments :
segment_count = len ( stale_segments )
for start in range ( 0 , len ( prior_rows ) - segment_count + 1 ) :
if prior_rows [ start : start + segment_count ] == stale_segments :
return True
# Already-polluted sessions may replay paragraphs from older polluted
# rows after newer clean turns have advanced the context tail. In that
# shape the stale paragraphs are still all prior user content, but they
# are substrings within older merged rows rather than standalone rows.
2026-06-17 05:04:34 +00:00
#
# NOTE: this substring match is intentionally loose (a stale segment can
# coincidentally appear inside an unrelated prior row). Correctness does
# NOT depend on it being precise — the caller (_detect_stale_user_merge)
# only reaches here once the row's suffix already normalizes to the
# ENTIRE submitted turn, so anything this branch flags has a prefix that
# is extra-to-the-submission. Cleaning therefore only ever rewrites the
# row to the user's actual current turn; it can never drop legitimate
# current-turn content even on a coincidental substring hit.
2026-06-17 02:20:53 +00:00
row_index = 0
row_offset = 0
matched_all_segments = True
for segment in stale_segments :
matched_segment = False
while row_index < len ( prior_rows ) :
row = prior_rows [ row_index ]
pos = row . find ( segment , row_offset )
if pos > = 0 :
row_offset = pos + len ( segment )
matched_segment = True
break
row_index + = 1
row_offset = 0
if not matched_segment :
matched_all_segments = False
break
if matched_all_segments :
return True
prefix_norm = _normalize_user_text ( stale_prefix )
if not prefix_norm :
return False
for row in prior_rows :
if row == prefix_norm or row . startswith ( f ' { prefix_norm } ' ) :
return True
return False
def _detect_stale_user_merge ( message , msg_text , previous_user_tail , previous_context = None ) :
2026-06-16 17:03:37 +00:00
""" Return True if `message` is the current user turn with a stale prefix merged in.
2026-06-17 02:20:53 +00:00
The agent ' s defensive repair path can concatenate prior user context with
the submitted current turn as ` ` < stale > \\n \\n < current > ` ` . The stale portion
can be either the immediate prior user tail or a replayed prefix from an
older already - polluted user row . The literal ` ` \\n \\n ` ` boundary must survive
into the comparison ; a single - newline or space - only join is not the repair
shape and must not match . Workspace sentinels may be present on either or
both halves and are stripped before comparison .
2026-06-16 17:03:37 +00:00
"""
if not isinstance ( message , dict ) or message . get ( ' role ' ) != ' user ' :
return False
current_norm = _normalize_user_text ( msg_text )
if not current_norm :
return False
2026-06-17 02:20:53 +00:00
merged = _raw_message_text ( message . get ( ' content ' , ' ' ) ) . replace ( " \r \n " , " \n " )
if " \n \n " not in merged :
2026-06-16 17:03:37 +00:00
return False
2026-06-17 02:20:53 +00:00
# The user's current turn can itself contain paragraph breaks. Find a repair
# boundary whose suffix normalizes to the *entire* submitted turn, then treat
# only the prefix as stale context. A plain split-and-last-segment check would
# miss ``<stale>\n\n<current paragraph A>\n\n<current paragraph B>``.
stale_segments = [ ]
stale_prefix = ' '
search_end = len ( merged )
while search_end > 0 :
boundary_idx = merged . rfind ( " \n \n " , 0 , search_end )
if boundary_idx < 0 :
break
suffix = merged [ boundary_idx + 2 : ]
if _normalize_user_text ( suffix ) == current_norm :
prefix = merged [ : boundary_idx ]
candidate_segments = [
_normalize_user_text ( segment )
for segment in prefix . split ( " \n \n " )
]
if candidate_segments and all ( candidate_segments ) :
stale_segments = candidate_segments
stale_prefix = prefix
break
# The suffix matched the submitted turn, but this boundary leaves
# blank prefix segments; try the next candidate boundary to the left.
search_end = boundary_idx
if not stale_segments :
2026-06-16 17:03:37 +00:00
return False
2026-06-17 02:20:53 +00:00
if previous_context is not None and _stale_prefix_matches_prior_user_context (
stale_prefix ,
stale_segments ,
previous_context ,
) :
return True
return bool (
previous_context is None
and len ( stale_segments ) == 1
and _normalize_user_text ( previous_user_tail ) == stale_segments [ 0 ]
)
2026-06-16 17:03:37 +00:00
2026-06-17 02:20:53 +00:00
def _strip_stale_user_merge_from_messages (
messages ,
msg_text ,
previous_user_tail ,
previous_context = None ,
) :
2026-06-16 17:03:37 +00:00
""" Return messages with stale-prefixed current user turns replaced by clean ones.
Both context - merge ( model - facing ) and display - merge ( visible transcript )
callers funnel through this so a single detection rule governs persistence .
The current user row is replaced with a clean copy using ` msg_text ` so the
displayed bubble matches what the human submitted , never the polluted pair .
"""
if not messages or not msg_text :
return messages
out = [ ]
for msg in messages :
2026-06-17 02:20:53 +00:00
if _detect_stale_user_merge (
msg ,
msg_text ,
previous_user_tail ,
previous_context = previous_context ,
) :
2026-06-16 17:03:37 +00:00
cleaned = copy . deepcopy ( msg ) if isinstance ( msg , dict ) else { ' role ' : ' user ' , ' content ' : msg_text }
cleaned [ ' content ' ] = msg_text
out . append ( cleaned )
else :
out . append ( msg )
return out
2026-05-25 13:21:11 +00:00
def _save_streaming_checkpoint ( session ) :
""" Persist a streaming checkpoint under the session ' s profile context. """
from api import profiles as profiles_api
with profiles_api . profile_env_for_background_worker (
session ,
" streaming checkpoint " ,
logger_override = logger ,
) :
session . save ( skip_index = True )
2026-05-17 00:22:22 +00:00
def _normalize_fresh_chat_text ( text ) :
text = _strip_workspace_prefix ( str ( text or ' ' ) , include_legacy = True )
text = re . sub ( r " \ s+ " , " " , text ) . strip ( ) . lower ( )
return text . strip ( " \t \r \n .!?。!?,, ~~ " )
def _is_casual_fresh_chat_message ( msg_text ) :
""" Return True for short opener messages that should not resume old tasks. """
text = _normalize_fresh_chat_text ( msg_text )
if not text or len ( text ) > 24 :
return False
continuation_terms = (
" continue " ,
" resume " ,
" carry on " ,
" go on " ,
# CJK continuation terms (zh-CN): jixu, jiezhe, wangxia, xiayibu.
# Encoded as Python escape sequences (not literal CJK) so api/streaming.py
# passes tests/test_title_sanitization.py::test_title_generation_source_has_no_cjk_literals,
# which scans this file for any U+4E00-U+9FFF code points. Runtime
# comparisons still use the real CJK strings — Python decodes the
# escapes at compile time.
" \u7ee7 \u7eed " ,
" \u63a5 \u7740 " ,
" \u5f80 \u4e0b " ,
" \u4e0b \u4e00 \u6b65 " ,
)
if any ( term in text for term in continuation_terms ) :
return False
return text in {
" hi " ,
" hello " ,
" hey " ,
" hello there " ,
" hi there " ,
# CJK greetings (zh-CN): nihao, ninhao, hai, haluo, zaima, zaime.
# Same escape-sequence rationale as the continuation block above.
" \u4f60 \u597d " , # nihao
" \u60a8 \u597d " , # ninhao
" \u55e8 " , # hai (was \u5616 = "click of tongue", not a greeting)
" \u54c8 \u55bd " , # haluo (was \u54c8\u5582 = uncommon "ha-wei" variant)
" \u5728 \u5417 " , # zaima
" \u5728 \u4e48 " , # zaime
}
def _has_task_resume_compaction_marker ( messages ) :
""" Detect compacted model context that tells the agent to resume an old task. """
for msg in messages or [ ] :
if not isinstance ( msg , dict ) :
continue
text = _message_text ( msg . get ( ' content ' , ' ' ) ) . lower ( )
if not text :
continue
if " context compaction " not in text and " context compression " not in text :
continue
if (
" active task " in text
or " resume exactly " in text
or " current task " in text
or " task list was preserved " in text
or " in_progress " in text
) :
return True
return False
2026-05-19 14:32:36 +00:00
def _new_turn_context_from_messages ( messages , msg_text ) :
""" Return provider-facing history for a new user turn from a message list. """
history = _drop_checkpointed_current_user_from_context ( messages , msg_text )
if _is_casual_fresh_chat_message ( msg_text ) and _has_task_resume_compaction_marker ( history ) :
return [ ]
return history
2026-05-17 00:22:22 +00:00
def _context_messages_for_new_turn ( session , msg_text ) :
""" Return provider-facing history for a new user turn.
Compacted agent sessions can carry a hidden " resume the active task " summary
2026-05-19 14:32:36 +00:00
in context_messages . If the user starts a fresh casual greeting in that old
session , do not feed that stale active - task summary back to the model .
2026-05-17 00:22:22 +00:00
"""
2026-05-19 14:32:36 +00:00
return _new_turn_context_from_messages ( _session_context_messages ( session ) , msg_text )
2026-05-17 00:22:22 +00:00
2026-05-12 11:55:31 +00:00
def _stream_writeback_is_current ( session , stream_id ) :
""" Return True only while a worker still owns the session writeback.
cancel_stream ( ) intentionally clears ` ` active_stream_id ` ` early so the UI can
accept a follow - up turn while the old worker is unwinding . That old worker
must not later persist its stale result over the newer transcript .
"""
return bool ( stream_id ) and getattr ( session , ' active_stream_id ' , None ) == stream_id
2026-05-19 14:26:45 +00:00
def _stream_writeback_can_supersede_recovery_marker ( session , msg_text ) :
""" Allow a finishing worker to replace its own stale-repair marker.
The stale - pending repair path can occasionally run while the original worker
is still alive but temporarily missing from the in - memory stream registry . It
clears ` ` active_stream_id ` ` and appends a " Response interrupted " marker . If
the original worker later finishes , treating ` ` active_stream_id is None ` ` as
stale drops the real answer and leaves the misleading marker visible .
This is intentionally narrow : only a session with no active / pending turn and
whose last visible row is the recovery marker for this exact user prompt may
be superseded . If a newer turn has appended anything after the marker , the
normal stale - writeback guard still wins .
"""
if getattr ( session , ' active_stream_id ' , None ) :
return False
if getattr ( session , ' pending_user_message ' , None ) :
return False
if getattr ( session , ' pending_attachments ' , None ) :
return False
messages = list ( getattr ( session , ' messages ' , None ) or [ ] )
if len ( messages ) < 2 :
return False
last = messages [ - 1 ]
if not isinstance ( last , dict ) or not last . get ( ' _error ' ) :
return False
if last . get ( ' type ' ) != ' interrupted ' :
return False
content = str ( last . get ( ' content ' ) or ' ' )
2026-05-22 08:41:10 +00:00
if ' Response interrupted ' not in content or ' before this turn finished ' not in content :
2026-05-19 14:26:45 +00:00
return False
expected = ' ' . join ( str ( msg_text or ' ' ) . split ( ) )
if not expected :
return False
for msg in reversed ( messages [ : - 1 ] ) :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' _error ' ) :
continue
if msg . get ( ' role ' ) != ' user ' :
continue
actual = ' ' . join ( str ( msg . get ( ' content ' ) or ' ' ) . split ( ) )
return actual == expected
return False
2026-06-23 09:55:29 +00:00
def _advance_truncation_watermark_after_commit ( session ) - > None :
""" Advance a positive truncation watermark once a new user turn is committed
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
to ` ` session . messages ` ` ( #3831).
retry / undo / Edit set a positive watermark to suppress the * replaced * tail from
the append - only state . db merge ; Session . save ( ) deliberately never auto - clears
2026-06-23 09:55:29 +00:00
it ( #2914). Once the new turn is durably in messages we advance the watermark
to the newest user message timestamp so that state . db rows newer than the
watermark are still merged in , while the replaced pre - edit tail remains
filtered . Never 0.0 ( the truncate - to - empty sentinel that must keep blocking
replay , #2914).
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
"""
2026-06-23 09:55:29 +00:00
if not getattr ( session , ' truncation_watermark ' , None ) :
return
messages = getattr ( session , ' messages ' , None ) or [ ]
# Walk backwards to find the newest user message timestamp
for msg in reversed ( messages ) :
if isinstance ( msg , dict ) and msg . get ( ' role ' ) == ' user ' :
ts = msg . get ( ' timestamp ' )
if isinstance ( ts , ( int , float ) ) and ts > 0 :
session . truncation_watermark = float ( ts )
return
2026-06-23 12:42:22 +00:00
session . truncation_watermark = time . time ( )
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
2026-06-18 22:21:10 +00:00
def _merge_display_messages_after_agent_result ( previous_display , previous_context , result_messages , msg_text , source : str = " webui " ) :
2026-04-29 08:37:08 +00:00
""" Keep UI transcript durable while allowing model context to compact.
If Hermes Agent returns a normal append - only history , append that delta to
the UI transcript . If the model / context history was compacted and no longer
has the prior context as a prefix , keep the previous UI transcript and append
only compaction marker messages plus the current user turn onward .
"""
previous_display = list ( previous_display or [ ] )
2026-05-21 17:56:46 +00:00
# Deduplicate stale _partial messages that accumulated in previous_display.
# A bug in cancel_stream() could insert multiple identical _partial messages
# when _stripped was empty but _has_reasoning/_has_tools was True. The
# merge's _message_identity previously returned None for empty _partial
# messages, so the seen-set couldn't catch them — they doubled each turn.
# Scan backwards and keep only the LAST occurrence of each unique _partial
# identity, then reverse back to original order.
_partial_seen = set ( )
_deduped_rev = [ ]
for m in reversed ( previous_display ) :
if isinstance ( m , dict ) and m . get ( ' _partial ' ) :
key = _message_identity ( m )
if key is not None :
if key in _partial_seen :
continue
_partial_seen . add ( key )
_deduped_rev . append ( m )
_deduped = list ( reversed ( _deduped_rev ) )
if len ( _deduped ) < len ( previous_display ) :
logger . debug (
" Deduplicated %d stale _partial messages from previous_display (was %d , now %d ) " ,
len ( previous_display ) - len ( _deduped ) , len ( previous_display ) , len ( _deduped ) ,
)
previous_display = _deduped
2026-04-29 08:37:08 +00:00
previous_context = list ( previous_context or [ ] )
result_messages = list ( result_messages or [ ] )
if not result_messages :
return previous_display
2026-06-16 17:03:37 +00:00
previous_user_tail = _stale_user_tail_candidate ( _last_user_row ( previous_context ) )
2026-04-29 08:37:08 +00:00
2026-05-21 16:20:26 +00:00
# ── Backfill normal turns from previous_context that are missing from
# previous_display. After context compression recovery, previous_context
# can contain user/assistant turns that were never rendered in the visible
# transcript (they were behind a compression marker). On the next
# append-only merge those turns sit inside the shared prefix and get
# stripped, leaving them permanently invisible. Reinsert them now.
#
# Use display as the backbone to preserve visible order. Walk display in
# order and for each display message search for its identity in context
# 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 :
_display_id_set = { _message_identity ( m ) for m in previous_display }
2026-06-02 18:28:41 +00:00
_context_id_set = {
_message_identity ( m )
for m in previous_context
if not _is_context_compression_marker ( m )
}
2026-05-21 16:20:26 +00:00
_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 ]
2026-06-16 16:54:14 +00:00
# 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 ]
# Multiset mirror of context_keys[_cursor:] kept in sync as _cursor
# advances. Enables O(1) membership tests in the any() check instead
# of an O(N) list scan, while preserving EXACT list-slice semantics:
# _message_identity intentionally returns duplicate keys for
# identical-content turns (and None for empty rows), so a plain set
# would drop a key still present later in the slice. A count-keyed
# dict (including None) matches `in context_keys[_cursor:]` exactly.
_remaining_ck_counts = { }
for _ck in context_keys :
_remaining_ck_counts [ _ck ] = _remaining_ck_counts . get ( _ck , 0 ) + 1
2026-05-21 16:20:26 +00:00
_backfilled = [ ]
2026-06-02 18:43:25 +00:00
# #3300 fix: track ONLY context rows we splice in, so the
# visible-display backbone is never suppressed. Sharing one set
# between context inserts and display rows (and _message_identity
# ignoring timestamps) dropped a legitimate second identical visible
# user turn. Display rows are always appended in order; a context
# row is backfilled only if it isn't already a display row and
# hasn't already been inserted.
_context_inserted = set ( )
2026-05-21 16:20:26 +00:00
_cursor = 0
2026-06-02 18:28:41 +00:00
for _display_idx , _dmsg in enumerate ( previous_display ) :
2026-06-16 16:54:14 +00:00
_dkey = _display_keys [ _display_idx ]
2026-05-21 16:20:26 +00:00
if _dkey is not None :
_j = _cursor
while _j < len ( context_keys ) and context_keys [ _j ] != _dkey :
_j + = 1
if _j < len ( context_keys ) :
for _k in range ( _cursor , _j ) :
_ckey = context_keys [ _k ]
_cmsg = previous_context [ _k ]
2026-06-02 18:43:25 +00:00
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker ( _cmsg ) :
2026-05-21 16:20:26 +00:00
_backfilled . append ( copy . deepcopy ( _cmsg ) )
2026-06-02 18:43:25 +00:00
_context_inserted . add ( _ckey )
2026-06-16 16:54:14 +00:00
# Sync multiset: decrement keys consumed by advancing
# the cursor to _j+1 (delete at zero so membership matches
# the list slice exactly).
for _k in range ( _cursor , _j + 1 ) :
_consumed_ck = context_keys [ _k ]
_ck_n = _remaining_ck_counts . get ( _consumed_ck , 0 ) - 1
if _ck_n < = 0 :
_remaining_ck_counts . pop ( _consumed_ck , None )
else :
_remaining_ck_counts [ _consumed_ck ] = _ck_n
2026-05-21 16:20:26 +00:00
_cursor = _j + 1
2026-06-02 18:28:41 +00:00
elif not any (
2026-06-16 16:54:14 +00:00
_display_keys [ _fi ] in _remaining_ck_counts
for _fi in range ( _display_idx + 1 , len ( _display_keys ) )
2026-06-02 18:28:41 +00:00
) :
for _k in range ( _cursor , len ( context_keys ) ) :
_ckey = context_keys [ _k ]
_cmsg = previous_context [ _k ]
2026-06-02 18:43:25 +00:00
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker ( _cmsg ) :
2026-06-02 18:28:41 +00:00
_backfilled . append ( copy . deepcopy ( _cmsg ) )
2026-06-02 18:43:25 +00:00
_context_inserted . add ( _ckey )
2026-06-02 18:28:41 +00:00
_cursor = len ( context_keys )
2026-06-16 16:54:14 +00:00
_remaining_ck_counts . clear ( )
2026-06-02 18:43:25 +00:00
# The display row is the visible backbone — always preserve it,
# in order, even when an earlier (identical-content) turn or a
# backfilled context row shares its timestamp-less identity.
_backfilled . append ( _dmsg )
2026-05-21 16:20:26 +00:00
while _cursor < len ( context_keys ) :
_ckey = context_keys [ _cursor ]
_cmsg = previous_context [ _cursor ]
_cursor + = 1
2026-06-02 18:43:25 +00:00
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker ( _cmsg ) :
2026-05-21 16:20:26 +00:00
_backfilled . append ( copy . deepcopy ( _cmsg ) )
2026-06-02 18:43:25 +00:00
_context_inserted . add ( _ckey )
2026-05-21 16:20:26 +00:00
if len ( _backfilled ) > len ( previous_display ) :
logger . debug (
" Backfilled %d context-only turns into previous_display (was %d , now %d ) " ,
len ( _backfilled ) - len ( previous_display ) ,
len ( previous_display ) ,
len ( _backfilled ) ,
)
previous_display = _backfilled
2026-04-29 08:37:08 +00:00
if _messages_have_prefix ( result_messages , previous_context ) :
candidates = result_messages [ len ( previous_context ) : ]
2026-06-16 17:03:37 +00:00
# Normalize stale merges only in the new-turn slice; never rewrite
# historical rows in the already-committed previous_context prefix.
if msg_text and previous_user_tail :
candidates = _strip_stale_user_merge_from_messages (
candidates ,
msg_text ,
previous_user_tail ,
2026-06-17 02:20:53 +00:00
previous_context = previous_context ,
2026-06-16 17:03:37 +00:00
)
2026-05-20 21:15:54 +00:00
candidates = _strip_replayed_prefix ( previous_display , candidates )
candidates = _strip_replayed_prefix ( previous_context , candidates )
2026-04-29 08:37:08 +00:00
else :
current_user_idx = _find_current_user_turn ( result_messages , msg_text )
marker_candidates = [
m for m in result_messages [ : current_user_idx if current_user_idx is not None else len ( result_messages ) ]
if _is_context_compression_marker ( m )
]
turn_candidates = result_messages [ current_user_idx : ] if current_user_idx is not None else [ ]
2026-06-16 17:03:37 +00:00
# Normalize stale merges only in the current-turn slice.
if msg_text and previous_user_tail :
turn_candidates = _strip_stale_user_merge_from_messages (
turn_candidates ,
msg_text ,
previous_user_tail ,
2026-06-17 02:20:53 +00:00
previous_context = previous_context ,
2026-06-16 17:03:37 +00:00
)
2026-04-29 08:37:08 +00:00
candidates = marker_candidates + turn_candidates
merged = previous_display [ : ]
seen = { _message_identity ( m ) for m in merged }
2026-05-04 21:05:49 +00:00
current_user_key = _message_identity ( { ' role ' : ' user ' , ' content ' : msg_text } )
2026-05-07 05:32:29 +00:00
current_user_in_candidates = any (
2026-05-12 14:38:29 +00:00
_message_identity ( m ) == current_user_key or _looks_like_current_user_turn ( m , msg_text )
for m in candidates
2026-05-07 05:32:29 +00:00
)
current_user_already_checkpointed = bool (
2026-05-12 14:38:29 +00:00
merged
and (
_message_identity ( merged [ - 1 ] ) == current_user_key
or _looks_like_current_user_turn ( merged [ - 1 ] , msg_text )
)
2026-05-07 05:32:29 +00:00
)
if (
current_user_key is not None
and not current_user_in_candidates
and not current_user_already_checkpointed
and any (
isinstance ( m , dict ) and m . get ( ' role ' ) in ( ' assistant ' , ' tool ' )
for m in candidates
)
) :
# Some provider retry/fallback paths can return an assistant/tool delta
# without echoing the current user turn. In deferred session-save mode
# the prompt exists only in pending_user_message, so appending that delta
# directly would make the assistant bubble appear attached to the prior
# exchange and then clear the pending prompt. Materialize the current
# turn at the transcript boundary before the assistant/tool response.
current_user_msg = { ' role ' : ' user ' , ' content ' : msg_text }
2026-06-18 22:21:10 +00:00
if source and source != ' webui ' :
current_user_msg [ ' _source ' ] = source
2026-05-07 05:32:29 +00:00
insert_at = 0
while insert_at < len ( candidates ) and _is_context_compression_marker ( candidates [ insert_at ] ) :
insert_at + = 1
candidates = candidates [ : insert_at ] + [ current_user_msg ] + candidates [ insert_at : ]
2026-04-29 08:37:08 +00:00
for msg in candidates :
key = _message_identity ( msg )
2026-05-12 14:38:29 +00:00
is_current_user_turn = _looks_like_current_user_turn ( msg , msg_text )
2026-05-04 21:05:49 +00:00
if (
2026-05-12 14:38:29 +00:00
( ( key is not None and key == current_user_key ) or is_current_user_turn )
2026-05-04 21:05:49 +00:00
and merged
2026-05-12 14:38:29 +00:00
and (
_message_identity ( merged [ - 1 ] ) == current_user_key
or _looks_like_current_user_turn ( merged [ - 1 ] , msg_text )
)
2026-05-04 21:05:49 +00:00
) :
# Eager session-save mode can checkpoint the current user turn
# before the agent runs. When the agent returns that same user turn
# in result_messages, keep the durable checkpoint and append only
# the assistant/tool delta.
continue
2026-05-11 05:06:11 +00:00
if (
key is not None
and isinstance ( msg , dict )
and msg . get ( ' role ' ) == ' assistant '
and merged
and _message_identity ( merged [ - 1 ] ) == key
) :
# Some provider/result replay paths can include the same assistant
# message twice in the current delta. Treat only adjacent identity
# matches as replay duplicates so identical answers in separate
# user turns remain visible.
continue
2026-04-29 08:37:08 +00:00
if _is_context_compression_marker ( msg ) and key is not None and key in seen :
continue
2026-05-08 10:14:13 +00:00
display_msg = msg
2026-05-12 14:38:29 +00:00
if (
( ( key is not None and key == current_user_key ) or is_current_user_turn )
and isinstance ( msg , dict )
and msg . get ( ' role ' ) == ' user '
) :
2026-05-08 10:14:13 +00:00
display_msg = copy . deepcopy ( msg )
display_msg [ ' content ' ] = msg_text
2026-06-18 22:21:10 +00:00
if source and source != ' webui ' :
display_msg [ ' _source ' ] = source
2026-05-08 10:14:13 +00:00
merged . append ( copy . deepcopy ( display_msg ) )
2026-04-29 08:37:08 +00:00
if key is not None :
seen . add ( key )
return merged
2026-05-28 17:58:18 +00:00
def _stamp_missing_message_timestamps ( messages , * , now : float | None = None ) - > int :
""" Stamp missing message timestamps without collapsing transcript order.
Compacted / reconciled rows can arrive without timestamps . Assigning one
integer seconds value to the whole batch makes later timestamp - based display
merges unstable ; use a subsecond sequence instead .
"""
base = time . time ( ) if now is None else float ( now )
stamped = 0
for msg in messages or [ ] :
if isinstance ( msg , dict ) and not msg . get ( ' timestamp ' ) and not msg . get ( ' _ts ' ) :
msg [ ' timestamp ' ] = base + ( stamped * 0.000001 )
stamped + = 1
return stamped
2026-05-12 14:38:29 +00:00
def _assistant_reply_added_after_current_turn ( result_messages , previous_context , msg_text ) - > bool :
""" Return True only when the just-finished turn produced assistant text. """
result_messages = list ( result_messages or [ ] )
previous_context = list ( previous_context or [ ] )
if _messages_have_prefix ( result_messages , previous_context ) :
candidates = result_messages [ len ( previous_context ) : ]
else :
current_user_idx = _find_current_user_turn ( result_messages , msg_text )
candidates = result_messages [ current_user_idx + 1 : ] if current_user_idx is not None else result_messages
return any (
isinstance ( m , dict )
and m . get ( ' role ' ) == ' assistant '
and not m . get ( ' _error ' )
and str ( m . get ( ' content ' ) or ' ' ) . strip ( )
for m in candidates
)
2026-06-06 05:45:09 +00:00
def _session_lacks_final_assistant_answer ( messages ) - > bool :
""" Return True when the persisted transcript ends before a final answer. """
for msg in reversed ( list ( messages or [ ] ) ) :
if not isinstance ( msg , dict ) :
continue
if msg . get ( ' _error ' ) :
return False
if _is_context_compression_marker ( msg ) :
continue
role = msg . get ( ' role ' )
if role == ' tool ' :
return True
if role == ' assistant ' :
content = msg . get ( ' content ' )
if isinstance ( content , list ) :
text = ' \n ' . join (
str ( part . get ( ' text ' ) or part . get ( ' content ' ) or ' ' )
for part in content
if isinstance ( part , dict )
)
else :
text = str ( content or ' ' )
if msg . get ( ' tool_calls ' ) :
return True
if text . strip ( ) :
return False
continue
if role == ' user ' :
return True
return True
def _agent_result_terminal_failure ( result ) - > bool :
""" Return True for agent results that must not be finalized as done. """
if not isinstance ( result , dict ) :
return False
status = str ( result . get ( ' status ' ) or result . get ( ' state ' ) or ' ' ) . strip ( ) . lower ( )
if status in { ' failed ' , ' error ' , ' partial ' , ' compression_exhausted ' } :
return True
if result . get ( ' compression_exhausted ' ) :
return True
if result . get ( ' failed ' ) or result . get ( ' partial ' ) :
return True
return False
2026-05-05 20:46:54 +00:00
_TOOL_RESULT_SNIPPET_MAX = 4000
2026-05-21 07:08:00 +00:00
_LIVE_TOOL_PROMPT_DELTA_MAX = 12_000
2026-05-25 07:54:01 +00:00
_LIVE_TOOL_PROMPT_TURN_MAX = 24_000
2026-05-21 07:08:00 +00:00
def _bounded_live_tool_prompt_delta ( messages , * , cap : int = _LIVE_TOOL_PROMPT_DELTA_MAX ) - > int :
""" Return a bounded rough token delta for live tool metering.
Tool - result callbacks can fire before the agent ' s next exact prompt accounting
is available . The live usage ring should show a conservative in - flight hint ,
not replay a full large tool payload into ` last_prompt_tokens ` .
"""
if not messages :
return 0
try :
from agent . model_metadata import estimate_messages_tokens_rough
delta = int ( estimate_messages_tokens_rough ( messages ) or 0 )
except Exception :
delta = 0
if delta < = 0 :
return 0
return min ( delta , int ( cap or 0 ) )
def live_usage_prompt_estimate_after_tool_delta (
* ,
base_prompt_tokens : int ,
exact_prompt_tokens : int = 0 ,
messages = None ,
cap : int = _LIVE_TOOL_PROMPT_DELTA_MAX ,
2026-05-25 07:54:01 +00:00
turn_tool_prompt_tokens : int = 0 ,
turn_cap : int = _LIVE_TOOL_PROMPT_TURN_MAX ,
2026-05-21 07:08:00 +00:00
) - > dict :
""" Compute the live `last_prompt_tokens` estimate after a tool update.
Exact compressor / provider prompt accounting wins . When no newer exact prompt
2026-05-25 07:54:01 +00:00
is available , add only bounded live tool deltas to the persisted base .
2026-05-21 07:08:00 +00:00
"""
base = int ( base_prompt_tokens or 0 )
exact = int ( exact_prompt_tokens or 0 )
if exact and exact != base :
2026-05-25 07:54:01 +00:00
return {
' last_prompt_tokens ' : exact ,
' estimated ' : False ,
' turn_tool_prompt_tokens ' : 0 ,
}
prior_turn_delta = max ( 0 , int ( turn_tool_prompt_tokens or 0 ) )
turn_ceiling = max ( 0 , int ( turn_cap or 0 ) )
next_turn_delta = min (
prior_turn_delta + _bounded_live_tool_prompt_delta ( messages , cap = cap ) ,
turn_ceiling ,
)
2026-05-21 07:08:00 +00:00
return {
2026-05-25 07:54:01 +00:00
' last_prompt_tokens ' : base + next_turn_delta ,
2026-05-21 07:08:00 +00:00
' estimated ' : True ,
2026-05-25 07:54:01 +00:00
' turn_tool_prompt_tokens ' : next_turn_delta ,
2026-05-21 07:08:00 +00:00
}
2026-06-25 14:11:46 +00:00
def _live_usage_session_snapshot ( session_id , current_session , cache_ref , * , loader = get_session ) :
""" Return a session object for hot live-metering paths without repeated loads. """
if current_session is not None :
try :
cache_ref [ 0 ] = current_session
except Exception :
pass
return current_session
try :
cached = cache_ref [ 0 ]
except Exception :
cached = None
if cached is not None :
return cached
try :
loaded = loader ( session_id )
except Exception :
return None
try :
cache_ref [ 0 ] = loaded
except Exception :
pass
return loaded
2026-05-05 20:46:54 +00:00
def _tool_result_snippet ( raw , limit : int = _TOOL_RESULT_SNIPPET_MAX ) - > str :
""" Extract a bounded result preview from a stored tool message payload. """
if limit < = 0 :
return ' '
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
text = str ( raw or ' ' )
try :
2026-05-05 20:46:54 +00:00
data = raw if isinstance ( raw , dict ) else json . loads ( text )
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
if isinstance ( data , dict ) :
2026-05-05 20:46:54 +00:00
preview = data . get ( ' output ' ) or data . get ( ' result ' ) or data . get ( ' error ' ) or text
text = str ( preview )
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
except Exception :
pass
2026-05-05 20:46:54 +00:00
return text [ : limit ]
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
def _truncate_tool_args ( args , limit : int = 6 ) - > dict :
""" Truncate tool args for compact session persistence. """
out = { }
if not isinstance ( args , dict ) :
return out
for k , v in list ( args . items ( ) ) [ : limit ] :
s = str ( v )
out [ k ] = s [ : 120 ] + ( ' ... ' if len ( s ) > 120 else ' ' )
return out
def _nearest_assistant_msg_idx ( messages , msg_idx : int ) - > int :
""" Find the closest preceding assistant message index for a tool result. """
for idx in range ( msg_idx - 1 , - 1 , - 1 ) :
msg = messages [ idx ]
if isinstance ( msg , dict ) and msg . get ( ' role ' ) == ' assistant ' :
return idx
return - 1
def _extract_tool_calls_from_messages ( messages , live_tool_calls = None ) :
""" Build persisted tool-call summaries from final messages plus live progress fallback. """
tool_calls = [ ]
pending_names = { }
pending_args = { }
pending_asst_idx = { }
tool_msg_sequence = [ ]
for msg_idx , m in enumerate ( messages or [ ] ) :
if not isinstance ( m , dict ) :
continue
role = m . get ( ' role ' )
if role == ' assistant ' :
content = m . get ( ' content ' , ' ' )
if isinstance ( content , list ) :
for part in content :
if isinstance ( part , dict ) and part . get ( ' type ' ) == ' tool_use ' :
tid = part . get ( ' id ' , ' ' )
if tid :
pending_names [ tid ] = part . get ( ' name ' , ' ' )
pending_args [ tid ] = part . get ( ' input ' , { } )
pending_asst_idx [ tid ] = msg_idx
for tc in m . get ( ' tool_calls ' , [ ] ) :
if not isinstance ( tc , dict ) :
continue
tid = tc . get ( ' id ' , ' ' ) or tc . get ( ' call_id ' , ' ' )
fn = tc . get ( ' function ' , { } )
name = fn . get ( ' name ' , ' ' )
try :
args = json . loads ( fn . get ( ' arguments ' , ' {} ' ) or ' {} ' )
except Exception :
args = { }
if tid and name :
pending_names [ tid ] = name
pending_args [ tid ] = args
pending_asst_idx [ tid ] = msg_idx
elif role == ' tool ' :
tid = m . get ( ' tool_call_id ' ) or m . get ( ' tool_use_id ' , ' ' )
raw = m . get ( ' content ' , ' ' )
seq = { ' msg_idx ' : msg_idx , ' raw ' : raw , ' resolved ' : False }
if tid :
name = pending_names . get ( tid , ' ' )
if name and name != ' tool ' :
tool_calls . append ( {
' name ' : name ,
' snippet ' : _tool_result_snippet ( raw ) ,
' tid ' : tid ,
' assistant_msg_idx ' : pending_asst_idx . get ( tid , - 1 ) ,
' args ' : _truncate_tool_args ( pending_args . get ( tid , { } ) ) ,
} )
seq [ ' resolved ' ] = True
tool_msg_sequence . append ( seq )
live = [ tc for tc in ( live_tool_calls or [ ] ) if isinstance ( tc , dict ) and tc . get ( ' name ' ) and tc . get ( ' name ' ) != ' clarify ' ]
if live :
for seq_idx , seq in enumerate ( tool_msg_sequence ) :
if seq . get ( ' resolved ' ) :
continue
if seq_idx > = len ( live ) :
break
live_tc = live [ seq_idx ]
tool_calls . append ( {
' name ' : live_tc . get ( ' name ' , ' tool ' ) ,
' snippet ' : _tool_result_snippet ( seq . get ( ' raw ' , ' ' ) ) ,
' tid ' : live_tc . get ( ' tid ' , ' ' ) or ' ' ,
' assistant_msg_idx ' : _nearest_assistant_msg_idx ( messages , seq . get ( ' msg_idx ' , - 1 ) ) ,
' args ' : _truncate_tool_args ( live_tc . get ( ' args ' , { } ) , limit = 4 ) ,
} )
return tool_calls
2026-05-19 18:15:35 +00:00
def _partial_message_signature ( message : dict ) - > tuple :
""" Return a stable identity for a persisted partial assistant marker. """
if not isinstance ( message , dict ) :
return ( ' ' , ' ' , ( ) )
tool_sig = [ ]
for tool_call in message . get ( ' _partial_tool_calls ' ) or [ ] :
if not isinstance ( tool_call , dict ) :
continue
try :
args_sig = json . dumps (
tool_call . get ( ' args ' ) or { } ,
ensure_ascii = False ,
sort_keys = True ,
default = str ,
)
except Exception :
args_sig = str ( tool_call . get ( ' args ' ) or ' ' )
tool_sig . append ( (
str ( tool_call . get ( ' name ' ) or ' ' ) ,
args_sig ,
bool ( tool_call . get ( ' done ' , False ) ) ,
bool ( tool_call . get ( ' is_error ' , False ) ) ,
str ( tool_call . get ( ' preview ' ) or tool_call . get ( ' snippet ' ) or ' ' ) ,
) )
return (
str ( message . get ( ' content ' ) or ' ' ) . strip ( ) ,
str ( message . get ( ' reasoning ' ) or ' ' ) . strip ( ) ,
tuple ( tool_sig ) ,
)
def _partial_marker_already_present ( messages , candidate : dict , * , before_idx : int | None = None ) - > bool :
""" Check for an equivalent partial marker in the current user turn only. """
if not isinstance ( messages , list ) or not isinstance ( candidate , dict ) :
return False
end = before_idx if isinstance ( before_idx , int ) else len ( messages )
end = max ( 0 , min ( end , len ( messages ) ) )
start = 0
for idx in range ( end - 1 , - 1 , - 1 ) :
msg = messages [ idx ]
if isinstance ( msg , dict ) and msg . get ( ' role ' ) == ' user ' :
start = idx + 1
break
candidate_sig = _partial_message_signature ( candidate )
for msg in messages [ start : end ] :
if isinstance ( msg , dict ) and msg . get ( ' _partial ' ) and _partial_message_signature ( msg ) == candidate_sig :
return True
return False
2026-03-31 03:40:19 +00:00
def _sse ( handler , event , data ) :
""" Write one SSE event to the response stream. """
payload = f " event: { event } \n data: { json . dumps ( data , ensure_ascii = False ) } \n \n "
handler . wfile . write ( payload . encode ( ' utf-8 ' ) )
handler . wfile . flush ( )
2026-06-09 05:36:18 +00:00
# ── SSE write deadline (Defect A: per-connection thread exhaustion) ─────────
# server.py runs QuietHTTPServer(ThreadingHTTPServer): one OS thread per
# connection, no pool cap (request_queue_size=64). Every SSE endpoint holds
# its thread for the connection's whole lifetime. If a tab is slow or
# backgrounded its TCP receive window fills; the next handler.wfile.write()/
# flush() then blocks *indefinitely* (sockets have no write timeout by
# default). That thread is pinned forever — it never reaches its
# `finally: unsubscribe`, so the SessionChannel reaper can never reclaim the
# channel either. N such tabs * M sessions pile threads up until new
# requests queue past request_queue_size and the UI shows "streaming
# pending".
#
# Fix: arm a socket-level timeout on the connection. A genuinely healthy
# keepalive/event write completes in well under a millisecond, so a
# multi-second deadline never trips for a live tab; only a backpressured
# (stuck) socket blocks past it. When it trips, the write raises
# socket.timeout — which on Python 3.10+ *is* TimeoutError, already a member
# of api.routes._CLIENT_DISCONNECT_ERRORS — so each SSE handler's existing
# `except _CLIENT_DISCONNECT_ERRORS:` breaks the loop, `finally` drops the
# subscriber, the browser's EventSource auto-reconnects, and the OS thread
# is released. SessionChannel already supports reconnect + offline buffer,
# so no events are lost for a tab that comes back. Operators behind unusual
# proxies can tune the deadline without code changes.
try :
_raw_deadline = os . getenv ( " HERMES_WEBUI_SSE_WRITE_DEADLINE " ) or os . getenv ( " HERMES_SSE_WRITE_DEADLINE " )
SSE_WRITE_DEADLINE_SECONDS = float ( _raw_deadline or " 20.0 " )
except ( TypeError , ValueError ) :
SSE_WRITE_DEADLINE_SECONDS = 20.0
if SSE_WRITE_DEADLINE_SECONDS < = 0 :
SSE_WRITE_DEADLINE_SECONDS = 20.0
def _sse_set_write_deadline ( handler , seconds = None ) :
""" Best-effort: arm a socket write deadline on an SSE handler.
Call once , right after end_headers ( ) , in every long - lived SSE endpoint .
Never raises — an unusual / missing transport just keeps the pre - fix
( no - deadline ) behaviour for that single connection rather than breaking
the stream setup .
"""
if seconds is None :
seconds = SSE_WRITE_DEADLINE_SECONDS
try :
conn = getattr ( handler , " connection " , None )
if conn is not None and hasattr ( conn , " settimeout " ) :
conn . settimeout ( seconds )
except Exception :
logger . debug ( " Failed to arm SSE write deadline " , exc_info = True )
2026-05-06 20:47:58 +00:00
def _materialize_pending_user_turn_before_error ( session ) - > bool :
""" Persist the pending user prompt before clearing runtime stream state.
Error paths often clear ` ` pending_user_message ` ` before appending an assistant
error marker . In deferred session - save mode that pending field can be the
only durable copy of the user ' s current turn, so clearing it makes the user
bubble disappear on reload / reconcile . Return True when a recovered user turn
was appended .
"""
pending_text = str ( getattr ( session , ' pending_user_message ' , None ) or ' ' )
if not pending_text :
return False
normalized_pending = " " . join ( pending_text . split ( ) )
if normalized_pending :
for existing in reversed ( list ( getattr ( session , ' messages ' , None ) or [ ] ) [ - 8 : ] ) :
if not isinstance ( existing , dict ) or existing . get ( ' role ' ) != ' user ' :
continue
existing_text = " " . join ( str ( existing . get ( ' content ' ) or ' ' ) . split ( ) )
if existing_text == normalized_pending :
return False
recovered_ts = int ( time . time ( ) )
pending_started_at = getattr ( session , ' pending_started_at ' , None )
if isinstance ( pending_started_at , ( int , float ) ) and pending_started_at > 0 :
recovered_ts = int ( pending_started_at )
recovered = {
' role ' : ' user ' ,
' content ' : pending_text ,
' timestamp ' : recovered_ts ,
' _recovered ' : True ,
}
2026-06-18 22:33:29 +00:00
pending_source = getattr ( session , ' pending_user_source ' , None )
if pending_source and pending_source != ' webui ' :
recovered [ ' _source ' ] = pending_source
2026-05-06 20:47:58 +00:00
pending_attachments = getattr ( session , ' pending_attachments ' , None )
if pending_attachments :
recovered [ ' attachments ' ] = list ( pending_attachments )
session . messages . append ( recovered )
2026-06-19 05:06:33 +00:00
# Mirror to context_messages so the _recovered flag survives the state.db
# round-trip (#4283). state.db has no _recovered column, so without this
# mirror the next turn's reconciled_state_db_messages_for_session(
# prefer_context=True) finds the recovered user as a flagless state.db
# delta and _sanitize_messages_for_api cannot filter it — causing the
# interrupted turn's prompt to be prepended to every subsequent turn.
# Placing the mirror here (rather than in _persist_cancelled_turn) covers
# all three callers: cancel, provider-error, and exception paths.
ctx = getattr ( session , ' context_messages ' , None )
if isinstance ( ctx , list ) and ctx :
rec_text = " " . join ( str ( recovered . get ( ' content ' ) or ' ' ) . split ( ) )
if not any (
isinstance ( e , dict ) and e . get ( ' role ' ) == ' user '
and " " . join ( str ( e . get ( ' content ' ) or ' ' ) . split ( ) ) == rec_text
for e in ctx [ - 8 : ]
) :
ctx . append ( { k : v for k , v in recovered . items ( ) if k != ' timestamp ' } )
2026-06-23 09:55:29 +00:00
# The new user turn is now committed to messages (#3831): advance a positive
# truncation watermark left over from a prior retry/undo/edit so that
# merge_session_messages_append_only() still filters out replaced pre-edit
# rows from state.db. The merge's sidecar_advanced_past_watermark guard
# allows state.db rows newer than the watermark, so post-edit turns are not
# dropped. Never 0.0 (the truncate-to-empty sentinel, #2914).
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
if getattr ( session , ' truncation_watermark ' , None ) :
2026-06-23 09:55:29 +00:00
session . truncation_watermark = float ( recovered_ts )
2026-05-06 20:47:58 +00:00
return True
2026-06-10 19:05:48 +00:00
def _build_partial_message ( content_text , reasoning_text , tool_calls ) - > dict | None :
""" Build a _partial assistant message from raw streaming buffers.
Shared by cancel_stream ( ) and _snapshot_and_append_partial_on_error ( ) .
Strips thinking / reasoning markup , builds the dict , returns None when
there is nothing meaningful to preserve .
"""
import re as _re
partial_text = ( content_text or ' ' ) . strip ( )
_stripped = ' '
if partial_text :
# First pass: remove complete <thinking>...</thinking> blocks.
_stripped = _re . sub ( r ' <think(?:ing)? \ b[^>]*>.*?</think(?:ing)?> ' ,
' ' , partial_text ,
flags = _re . DOTALL | _re . IGNORECASE ) . strip ( )
# Second pass: strip trailing UNCLOSED think/thinking block (the common
# cancel/error case — user stops mid-reasoning before the close tag appears).
_stripped = _re . sub ( r ' <think(?:ing)? \ b[^>]*>.* ' ,
' ' , _stripped ,
flags = _re . DOTALL | _re . IGNORECASE ) . strip ( )
_has_reasoning = bool ( reasoning_text and reasoning_text . strip ( ) )
_has_tools = bool ( tool_calls )
if not ( _stripped or _has_reasoning or _has_tools ) :
return None
_msg : dict = {
' role ' : ' assistant ' ,
' content ' : _stripped , # may be empty for reasoning/tool-only turns
' _partial ' : True ,
' timestamp ' : int ( time . time ( ) ) ,
}
if _has_reasoning :
_msg [ ' reasoning ' ] = reasoning_text . strip ( )
if _has_tools :
_msg [ ' _partial_tool_calls ' ] = list ( tool_calls )
return _msg
def _snapshot_and_append_partial_on_error ( session , stream_id ) - > dict | None :
""" Snapshot streaming buffers under STREAMS_LOCK and append a _partial message.
Uses _build_partial_message ( ) for the shared thinking - strip + dict - build logic .
"""
from api import config as _live_config
streams_lock = STREAMS_LOCK
partial_texts = STREAM_PARTIAL_TEXT
reasoning_texts = STREAM_REASONING_TEXT
live_tool_calls = STREAM_LIVE_TOOL_CALLS
# Defensive check for live config (similar to cancel_stream)
if getattr ( _live_config , ' STREAMS_LOCK ' , streams_lock ) is not streams_lock :
streams_lock = _live_config . STREAMS_LOCK
partial_texts = getattr ( _live_config , ' STREAM_PARTIAL_TEXT ' , partial_texts )
reasoning_texts = getattr ( _live_config , ' STREAM_REASONING_TEXT ' , reasoning_texts )
live_tool_calls = getattr ( _live_config , ' STREAM_LIVE_TOOL_CALLS ' , live_tool_calls )
_snap_partial_text = None
_snap_reasoning = None
_snap_tool_calls = None
with streams_lock :
_snap_partial_text = partial_texts . get ( stream_id , ' ' )
if not _snap_partial_text :
_live_partials = getattr ( _live_config , ' STREAM_PARTIAL_TEXT ' , partial_texts )
if _live_partials is not partial_texts :
_snap_partial_text = _live_partials . get ( stream_id , ' ' )
_snap_reasoning = reasoning_texts . get ( stream_id , ' ' )
if not _snap_reasoning :
_live_reasoning = getattr ( _live_config , ' STREAM_REASONING_TEXT ' , reasoning_texts )
if _live_reasoning is not reasoning_texts :
_snap_reasoning = _live_reasoning . get ( stream_id , ' ' )
_snap_tool_calls = list ( live_tool_calls . get ( stream_id , [ ] ) or [ ] )
if not _snap_tool_calls :
_live_tools = getattr ( _live_config , ' STREAM_LIVE_TOOL_CALLS ' , live_tool_calls )
if _live_tools is not live_tool_calls :
_snap_tool_calls = list ( _live_tools . get ( stream_id , [ ] ) or [ ] )
_partial_msg = _build_partial_message ( _snap_partial_text , _snap_reasoning , _snap_tool_calls )
if _partial_msg is None :
return None
if not isinstance ( session . messages , list ) :
session . messages = [ ]
if not _partial_marker_already_present ( session . messages , _partial_msg ) :
session . messages . append ( _partial_msg )
return _partial_msg
return None
2026-04-28 07:19:17 +00:00
def _last_resort_sync_from_core ( session , stream_id , agent_lock ) :
""" Final-exit guard: if the stream exits with pending_user_message still set,
sync messages from the core transcript or add an error marker .
Called from the outer finally block of _run_agent_streaming .
Must never raise .
"""
2026-04-28 10:09:26 +00:00
from api . models import _get_profile_home , _apply_core_sync_or_error_marker
2026-04-28 07:19:17 +00:00
try :
2026-04-28 10:09:26 +00:00
# Guard: if a cancel was already requested, bail out — cancel_stream() has
# already saved partial content and we must not double-append error markers.
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS [ stream_id ] . is_set ( ) :
return
2026-04-28 07:19:17 +00:00
2026-04-28 10:09:26 +00:00
profile_home = _get_profile_home ( session . profile )
core_path = profile_home / ' sessions ' / f ' session_ { session . session_id } .json '
2026-04-28 07:19:17 +00:00
_lock_ctx = agent_lock if agent_lock is not None else contextlib . nullcontext ( )
with _lock_ctx :
2026-04-28 10:09:26 +00:00
_apply_core_sync_or_error_marker (
session ,
core_path ,
stream_id_for_recheck = stream_id ,
require_stream_dead = False ,
2026-04-28 07:19:17 +00:00
)
except Exception :
logger . exception (
" _last_resort_sync_from_core failed for session %s " ,
getattr ( session , ' session_id ' , ' ? ' ) ,
)
2026-06-04 20:04:01 +00:00
def _build_session_db_for_stream ( state_db_path ) :
""" Build a per-request SessionDB handle for WebUI session search.
Returns ` ` None ` ` if the helper module or constructor fails so callers can
continue without session_search rather than propagating a hard failure .
"""
try :
from hermes_state import SessionDB
return SessionDB ( db_path = state_db_path )
except Exception as _db_err :
print ( f " [webui] WARNING: SessionDB init failed - session_search will be unavailable: { _db_err } " , flush = True )
return None
def _replace_session_db_in_kwargs ( agent_kwargs , state_db_path ) :
""" Build a fresh SessionDB and replace ``agent_kwargs[ ' session_db ' ]`` safely. """
if not isinstance ( agent_kwargs , dict ) :
return None
_old_session_db = agent_kwargs . get ( " session_db " )
_next_session_db = _build_session_db_for_stream ( state_db_path )
if _old_session_db is not None and _old_session_db is not _next_session_db :
try :
_old_session_db . close ( )
except Exception :
logger . debug ( " Failed to close previous session_db handle during self-heal " )
agent_kwargs [ " session_db " ] = _next_session_db
return _next_session_db
2026-05-03 18:32:53 +00:00
def _attempt_credential_self_heal (
2026-06-12 01:08:24 +00:00
provider_id , session_id , _agent_lock_ref , * , target_model = None ,
2026-05-03 18:32:53 +00:00
) :
""" Try to silently refresh credentials after a 401/auth error (#1401).
Returns a new ` ` ( agent , rt_dict ) ` ` tuple on success so the caller can
retry the conversation . Returns ` ` None ` ` when self - heal is not
applicable ( e . g . auth . json unchanged , provider unresolvable ) .
Steps :
1. Re - read ` ` ~ / . hermes / auth . json ` ` to pick up fresh credentials that
may have been written by a concurrent ` ` hermes model ` ` CLI invocation .
2. Evict the session ' s cached agent so it is rebuilt with fresh keys.
3. Evict the provider ' s credential-pool cache entry.
4. Re - resolve the runtime provider .
5. Return a new agent + resolved - provider dict ( the caller must
re - invoke ` ` run_conversation ` ` with these ) .
"""
try :
2026-05-07 00:22:20 +00:00
from api . oauth import (
read_auth_json ,
resolve_runtime_provider_with_anthropic_env_lock ,
)
2026-05-03 18:32:53 +00:00
from api . config import (
SESSION_AGENT_CACHE , SESSION_AGENT_CACHE_LOCK ,
invalidate_credential_pool_cache ,
)
from hermes_cli . runtime_provider import resolve_runtime_provider
# 1. Re-read auth.json (triggers a fresh credential scan)
_fresh_auth = read_auth_json ( )
if not _fresh_auth :
logger . debug ( ' [webui] self-heal: auth.json empty or missing, skipping ' )
return None
# 2. Evict the cached agent for this session
2026-05-30 23:22:56 +00:00
_evicted_entry = None
2026-05-03 18:32:53 +00:00
with SESSION_AGENT_CACHE_LOCK :
2026-05-30 23:22:56 +00:00
_evicted_entry = SESSION_AGENT_CACHE . pop ( session_id , None )
if _evicted_entry is not None :
_close_cached_agent_entry_at_session_boundary ( session_id , _evicted_entry )
2026-05-03 18:32:53 +00:00
# 3. Invalidate the credential pool for this provider
invalidate_credential_pool_cache ( provider_id )
# 4. Re-resolve runtime provider with fresh credentials
2026-05-07 00:22:20 +00:00
_new_rt = resolve_runtime_provider_with_anthropic_env_lock (
resolve_runtime_provider ,
requested = provider_id ,
2026-06-12 01:08:24 +00:00
target_model = target_model ,
2026-05-07 00:22:20 +00:00
)
2026-05-03 18:32:53 +00:00
logger . info (
' [webui] self-heal: credential refresh succeeded for provider= %s session= %s ' ,
provider_id , session_id ,
)
return _new_rt
except Exception as _heal_err :
logger . warning (
' [webui] self-heal: failed for provider= %s session= %s : %s ' ,
provider_id , session_id , _heal_err ,
)
return None
2026-05-16 18:48:20 +00:00
def _agent_cache_api_key_sig ( resolved_api_key , credential_pool ) - > str :
""" Return the cache-signature component for runtime credentials.
Credential - pool providers can legitimately hand WebUI a different runtime
token on each request ( round - robin pools , OAuth refresh , auth self - heal ) .
The AIAgent object is also where cross - turn memory - provider state lives , so
using the volatile token itself in the cache signature silently defeats the
per - session agent cache and drops warmed Hindsight prefetch results .
"""
if credential_pool is not None :
return ' credential-pool '
import hashlib as _hashlib
return _hashlib . sha256 ( ( resolved_api_key or ' ' ) . encode ( ) ) . hexdigest ( ) [ : 16 ]
2026-05-30 04:07:06 +00:00
def _lifecycle_commit_session_memory ( session_id : str , * , agent = None , wait : bool = False ) - > bool :
from api . session_lifecycle import commit_session_memory
return commit_session_memory ( session_id , agent = agent , wait = wait )
def _lifecycle_has_uncommitted_work ( session_id : str ) - > bool :
from api . session_lifecycle import has_uncommitted_work
return has_uncommitted_work ( session_id )
def _lifecycle_unregister_agent ( session_id : str ) - > None :
from api . session_lifecycle import unregister_agent
unregister_agent ( session_id )
Release v0.51.256 — Release HX (stage-r4) (#3596)
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 19:28:06 +00:00
def _lifecycle_discard_session ( session_id : str ) - > bool :
from api . session_lifecycle import discard_session
return discard_session ( session_id )
2026-05-30 04:07:06 +00:00
def _close_evicted_agent_at_session_boundary ( session_id : str , agent ) - > bool :
""" Commit and tear down an evicted cached agent at a WebUI session boundary.
WebUI keeps AIAgent instances in an LRU cache so memory providers can carry
state across turns . When an agent is evicted , commit pending memory first ;
if the lifecycle entry is clean afterwards , unregister and call
shutdown_memory_provider ( messages ) so provider - owned clients such as
Hindsight ' s aiohttp session are closed instead of being garbage-collected
later . Passing the cached transcript mirrors gateway cleanup semantics for
providers that use on_session_end ( messages ) during shutdown .
"""
if agent is None :
return True
should_close_evicted_agent = True
try :
_lifecycle_commit_session_memory ( session_id , agent = agent , wait = True )
if not _lifecycle_has_uncommitted_work ( session_id ) :
_lifecycle_unregister_agent ( session_id )
Release v0.51.256 — Release HX (stage-r4) (#3596)
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 19:28:06 +00:00
# Drop the lifecycle dict entry now that the LRU-evicted agent is
# gone and no uncommitted work remains, so the dict tracks only live
# sessions instead of growing unbounded (issue #3506).
_lifecycle_discard_session ( session_id )
2026-05-30 04:07:06 +00:00
else :
should_close_evicted_agent = False
except Exception :
should_close_evicted_agent = False
logger . debug ( " Lifecycle commit on eviction failed for %s " , session_id , exc_info = True )
if not should_close_evicted_agent :
return False
try :
shutdown_memory_provider = getattr ( agent , ' shutdown_memory_provider ' , None )
if callable ( shutdown_memory_provider ) :
session_messages = vars ( agent ) . get ( ' _session_messages ' , [ ] )
shutdown_memory_provider ( session_messages )
except Exception :
logger . debug ( " Failed to shut down evicted agent memory provider for session %s " , session_id , exc_info = True )
try :
session_db = getattr ( agent , ' _session_db ' , None )
if session_db is not None :
session_db . close ( )
except Exception :
logger . debug ( " Failed to close evicted agent session DB for session %s " , session_id , exc_info = True )
return True
2026-05-30 23:22:56 +00:00
def _close_cached_agent_entry_at_session_boundary ( session_id : str , cache_entry ) - > bool :
""" Commit and tear down a popped SESSION_AGENT_CACHE entry outside the cache lock. """
agent = cache_entry [ 0 ] if isinstance ( cache_entry , tuple ) else None
return _close_evicted_agent_at_session_boundary ( session_id , agent )
2026-05-16 18:48:20 +00:00
def _refresh_cached_agent_runtime ( agent , agent_kwargs : dict ) - > bool :
""" Refresh volatile runtime credentials on a reused cached AIAgent.
The cache key intentionally ignores credential - pool token churn , but the
cached agent ' s LLM client still needs the latest selected/refreshed runtime
key . Keep long - lived provider / session state ( memory prefetch , turn counters ,
tool state ) while swapping only the runtime credential / client .
"""
if agent is None or not isinstance ( agent_kwargs , dict ) :
return False
new_pool = agent_kwargs . get ( ' credential_pool ' )
if new_pool is not None :
try :
agent . _credential_pool = new_pool
except Exception :
pass
new_key = agent_kwargs . get ( ' api_key ' ) or ' '
if not new_key :
return True
new_base = agent_kwargs . get ( ' base_url ' ) or getattr ( agent , ' base_url ' , ' ' ) or ' '
if getattr ( agent , ' _fallback_activated ' , False ) :
# Avoid mixing a refreshed primary credential into a live fallback
# runtime. Rebuilding is safer than mutating a fallback-active agent
# whose restore/cooldown state has not run yet for this turn.
return False
if new_key == ( getattr ( agent , ' api_key ' , ' ' ) or ' ' ) :
_refresh_cached_agent_primary_runtime_snapshot ( agent )
return True
try :
if getattr ( agent , ' api_mode ' , None ) == ' anthropic_messages ' :
# Native Anthropic-style clients have their own construction path;
# switch_model() already handles token/client refresh there.
if hasattr ( agent , ' switch_model ' ) :
agent . switch_model (
agent_kwargs . get ( ' model ' ) or getattr ( agent , ' model ' , None ) ,
agent_kwargs . get ( ' provider ' ) or getattr ( agent , ' provider ' , None ) ,
api_key = new_key ,
base_url = new_base ,
api_mode = agent_kwargs . get ( ' api_mode ' ) or getattr ( agent , ' api_mode ' , ' ' ) ,
)
return True
return False
if not hasattr ( agent , ' _client_kwargs ' ) or not hasattr ( agent , ' _replace_primary_openai_client ' ) :
# Test/fake-agent fallback: keep metadata accurate even if no real
# OpenAI client exists to rebuild.
agent . api_key = new_key
if new_base :
agent . base_url = new_base
_refresh_cached_agent_primary_runtime_snapshot ( agent )
return True
client_kwargs = dict ( getattr ( agent , ' _client_kwargs ' , { } ) or { } )
client_kwargs [ ' api_key ' ] = new_key
if new_base :
client_kwargs [ ' base_url ' ] = new_base
agent . _client_kwargs = client_kwargs
agent . api_key = new_key
if new_base :
agent . base_url = new_base
if hasattr ( agent , ' _apply_client_headers_for_base_url ' ) :
agent . _apply_client_headers_for_base_url ( agent . base_url )
rebuilt = bool ( agent . _replace_primary_openai_client ( reason = ' webui_credential_refresh ' ) )
if rebuilt :
_refresh_cached_agent_primary_runtime_snapshot ( agent )
return rebuilt
except Exception :
logger . debug ( ' [webui] Failed to refresh cached agent runtime credentials ' , exc_info = True )
return False
2026-05-30 16:13:46 +00:00
def _cached_agent_session_identity ( agent ) - > str | None :
""" Best-effort session id carried by a cached AIAgent.
The cache key is only safe when it agrees with the object ' s own session
identity . Some old / fake agents may not expose an identity ; keep those
backwards - compatible and treat them as unverifiable rather than mismatched .
"""
if agent is None :
return None
for attr in ( ' session_id ' , ' _session_id ' ) :
value = getattr ( agent , attr , None )
if isinstance ( value , str ) and value :
return value
session_db = getattr ( agent , ' _session_db ' , None )
if session_db is not None :
for attr in ( ' session_id ' , ' _session_id ' ) :
value = getattr ( session_db , attr , None )
if isinstance ( value , str ) and value :
return value
return None
def _cached_agent_matches_session ( agent , session_id : str ) - > bool :
identity = _cached_agent_session_identity ( agent )
return identity is None or identity == str ( session_id )
2026-05-16 18:48:20 +00:00
def _refresh_cached_agent_primary_runtime_snapshot ( agent ) - > None :
""" Keep AIAgent ' s primary-runtime snapshot aligned with refreshed creds.
Long - lived AIAgent instances use ` _primary_runtime ` to restore the preferred
provider after fallback / transport recovery . If WebUI refreshes a cached
agent ' s runtime token but leaves that snapshot stale, a later restore can
resurrect the old credential and undo the refresh .
"""
rt = getattr ( agent , ' _primary_runtime ' , None )
if not isinstance ( rt , dict ) :
return
base_url = getattr ( agent , ' base_url ' , rt . get ( ' base_url ' ) )
api_key = getattr ( agent , ' api_key ' , rt . get ( ' api_key ' , ' ' ) )
client_kwargs = dict ( getattr ( agent , ' _client_kwargs ' , None ) or rt . get ( ' client_kwargs ' , { } ) or { } )
rt [ ' base_url ' ] = base_url
rt [ ' api_key ' ] = api_key
rt [ ' client_kwargs ' ] = client_kwargs
# The default context compressor usually tracks the primary runtime too;
# keep both the live compressor fields and the fallback-restoration
# snapshot aligned when those attributes exist.
cc = getattr ( agent , ' context_compressor ' , None )
if cc is not None :
if hasattr ( cc , ' base_url ' ) :
cc . base_url = base_url
if hasattr ( cc , ' api_key ' ) :
cc . api_key = api_key
if ' compressor_base_url ' in rt :
rt [ ' compressor_base_url ' ] = getattr ( cc , ' base_url ' , base_url )
if ' compressor_api_key ' in rt :
rt [ ' compressor_api_key ' ] = getattr ( cc , ' api_key ' , api_key )
else :
if ' compressor_base_url ' in rt :
rt [ ' compressor_base_url ' ] = base_url
if ' compressor_api_key ' in rt :
rt [ ' compressor_api_key ' ] = api_key
if getattr ( agent , ' api_mode ' , None ) == ' anthropic_messages ' :
if hasattr ( agent , ' _anthropic_api_key ' ) :
rt [ ' anthropic_api_key ' ] = getattr ( agent , ' _anthropic_api_key ' )
if hasattr ( agent , ' _anthropic_base_url ' ) :
rt [ ' anthropic_base_url ' ] = getattr ( agent , ' _anthropic_base_url ' )
if hasattr ( agent , ' _is_anthropic_oauth ' ) :
rt [ ' is_anthropic_oauth ' ] = getattr ( agent , ' _is_anthropic_oauth ' )
2026-05-01 05:23:47 +00:00
def _run_agent_streaming (
session_id ,
msg_text ,
model ,
workspace ,
stream_id ,
attachments = None ,
* ,
ephemeral = False ,
model_provider = None ,
2026-05-09 07:08:13 +00:00
goal_related = False ,
2026-05-01 05:23:47 +00:00
) :
2026-04-24 01:24:51 +00:00
""" Run agent in background thread, writing SSE events to STREAMS[stream_id].
When ephemeral = True , session mutations are skipped — used by / btw to get
a streaming answer without persisting to the parent session .
"""
2026-03-31 03:40:19 +00:00
q = STREAMS . get ( stream_id )
if q is None :
return
2026-05-11 00:15:00 +00:00
register_active_run (
stream_id ,
session_id = session_id ,
started_at = time . time ( ) ,
phase = " starting " ,
workspace = str ( workspace ) ,
model = model ,
provider = model_provider ,
ephemeral = bool ( ephemeral ) ,
)
2026-05-15 01:37:54 +00:00
try :
run_journal = RunJournalWriter ( session_id , stream_id )
except Exception :
run_journal = None
logger . debug ( " Failed to initialize run journal for stream %s " , stream_id , exc_info = True )
2026-05-11 07:08:51 +00:00
if not ephemeral :
try :
append_turn_journal_event_for_stream (
session_id ,
stream_id ,
{ " event " : " worker_started " , " created_at " : time . time ( ) } ,
)
except Exception :
logger . debug ( " Failed to append worker_started turn journal event " , exc_info = True )
2026-04-16 17:20:42 +00:00
s = None
_rt = { }
old_cwd = None
old_exec_ask = None
old_session_key = None
2026-05-15 00:22:44 +00:00
old_session_id = None
old_session_platform = None
2026-04-16 17:20:42 +00:00
old_hermes_home = None
2026-04-29 06:12:59 +00:00
old_profile_env = { }
2026-03-31 03:40:19 +00:00
fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation
Issue #1968: switching to a non-default profile in the WebUI dropdown
had no effect on which MCP servers were available. Every chat session,
regardless of profile, only saw the default profile's mcp_servers from
~/.hermes/config.yaml. Non-default profile MCP servers (postgres, custom
stdio servers, anything in <profile>/config.yaml) never registered.
Root cause: api/streaming.py:1922 called discover_mcp_tools() at the
TOP of _run_agent_streaming(), about 100 lines BEFORE the per-session
'os.environ["HERMES_HOME"] = _profile_home' mutation at line 2053.
discover_mcp_tools() reads ~/.hermes/config.yaml via get_hermes_home(),
which uses os.environ['HERMES_HOME']. So at the call site, HERMES_HOME
was still whatever the WebUI server process had at startup — the default
profile, every time.
Fix: relocate the discover_mcp_tools() call past the _ENV_LOCK block so
get_hermes_home() resolves to the session's actual profile home. Same
try/except wrapping is preserved; same idempotency semantics on
already-connected servers; same lazy-import pattern.
Caveat (out of scope, agent-side): _servers in tools/mcp_tool.py is a
process-global Dict[str, MCPServerTask] keyed only by server name. So
once profile A registers a server named e.g. 'postgres', profile B's
discovery sees 'postgres' as already connected and skips it — even if
B's config points at a different binary or DB. Concurrent multi-profile
WebUI processes will still hit 'first profile wins per server name'.
Fully fixing that requires keying _servers by (profile_home, name)
upstream in hermes-agent. This PR ships layer 1 only — fixes the
single-non-default-profile case (the headline symptom).
Tests: tests/test_issue1968_mcp_profile_discovery.py — 4 static tests
pinning the lexical ordering invariants. Verified mutation-safety: a
proof-of-concept revert (re-adding a discover call before the
HERMES_HOME mutation) makes the 'only called once' test fail.
Test suite: 5047 passed, 4 skipped, 3 xpassed, 0 regressions.
Closes #1968
2026-05-09 20:08:16 +00:00
# MCP discovery moved to AFTER the per-profile HERMES_HOME mutation below
# (was here at v0.51.30) — the previous placement always read the default
# profile's mcp_servers because os.environ['HERMES_HOME'] hadn't been
# rewritten yet. See https://github.com/nesquena/hermes-webui/issues/1968.
2026-04-15 05:10:50 +00:00
2026-03-31 03:40:19 +00:00
# Sprint 10: create a cancel event for this stream
cancel_event = threading . Event ( )
with STREAMS_LOCK :
CANCEL_FLAGS [ stream_id ] = cancel_event
2026-04-23 18:16:59 +00:00
STREAM_PARTIAL_TEXT [ stream_id ] = ' ' # start accumulating partial text (#893)
2026-04-30 23:24:29 +00:00
STREAM_REASONING_TEXT [ stream_id ] = ' ' # start accumulating reasoning trace (#1361 §A)
STREAM_LIVE_TOOL_CALLS [ stream_id ] = [ ] # start accumulating tool calls (#1361 §B)
2026-03-31 03:40:19 +00:00
2026-05-10 12:51:46 +00:00
agent = None
_live_prompt_estimate_tokens = [ 0 ]
2026-05-10 16:31:14 +00:00
_live_prompt_exact_tokens = [ 0 ]
2026-05-25 07:54:01 +00:00
_live_prompt_estimate_tool_delta_tokens = [ 0 ]
2026-05-10 12:51:46 +00:00
_live_prompt_estimate_seen_ids = set ( )
2026-05-31 13:27:17 +00:00
# Per-stream cache for the real per-model context_length (#3256 perf).
# _live_usage_snapshot() runs on every metering tick (~10x/sec during
# streaming); recomputing get_model_context_length() there triggered a
# config read + potential metadata/network probe on every token for
# non-default models (e.g. claude-opus-4.7-1m), freezing the stream while
# the default model was unaffected. The value is constant for a given
# (model, base_url, provider) within one stream, so resolve it at most
# once. Sentinel: None=not computed, 0=not applicable/failed, >0=real cap.
_real_ctx_cache = [ None ]
2026-06-25 14:11:46 +00:00
_live_usage_session_cache = [ None ]
def _current_live_usage_session ( ) :
return _live_usage_session_snapshot (
session_id ,
s ,
_live_usage_session_cache ,
)
2026-05-10 12:51:46 +00:00
def _seed_live_prompt_estimate ( ) - > int :
""" Capture the latest exact prompt size before adding live tool deltas. """
if _live_prompt_estimate_tokens [ 0 ] > 0 :
return _live_prompt_estimate_tokens [ 0 ]
_base = 0
_agent = agent
if _agent is not None :
try :
_cc = getattr ( _agent , ' context_compressor ' , None )
if _cc :
_base = getattr ( _cc , ' last_prompt_tokens ' , 0 ) or 0
except Exception :
_base = 0
if not _base :
try :
2026-06-25 14:11:46 +00:00
_session_obj = _current_live_usage_session ( )
2026-05-10 12:51:46 +00:00
_base = getattr ( _session_obj , ' last_prompt_tokens ' , 0 ) or 0
except Exception :
_base = 0
_live_prompt_estimate_tokens [ 0 ] = int ( _base or 0 )
2026-05-10 16:31:14 +00:00
_live_prompt_exact_tokens [ 0 ] = _live_prompt_estimate_tokens [ 0 ]
2026-05-10 12:51:46 +00:00
return _live_prompt_estimate_tokens [ 0 ]
def _bump_live_prompt_estimate ( messages ) - > int :
""" Increment a rough next-prompt estimate from live tool activity. """
if not messages :
return _live_prompt_estimate_tokens [ 0 ]
2026-05-25 07:54:01 +00:00
_seed_live_prompt_estimate ( )
_usage = live_usage_prompt_estimate_after_tool_delta (
base_prompt_tokens = _live_prompt_exact_tokens [ 0 ] ,
exact_prompt_tokens = _live_prompt_exact_tokens [ 0 ] ,
messages = messages ,
turn_tool_prompt_tokens = _live_prompt_estimate_tool_delta_tokens [ 0 ] ,
)
_live_prompt_estimate_tokens [ 0 ] = _usage [ ' last_prompt_tokens ' ]
_live_prompt_estimate_tool_delta_tokens [ 0 ] = _usage [ ' turn_tool_prompt_tokens ' ]
2026-05-10 12:51:46 +00:00
return _live_prompt_estimate_tokens [ 0 ]
def _live_usage_snapshot ( ) :
""" Best-effort live usage payload for mid-stream UI updates.
During tool execution the final ` done ` event has not fired yet , but the
frontend still benefits from seeing the latest known token / context
values . These are exact for the most recent model call and a truthful
lower bound for the pending next call after a tool result is appended .
"""
_usage = {
' input_tokens ' : 0 ,
' output_tokens ' : 0 ,
' estimated_cost ' : 0 ,
2026-05-17 02:49:34 +00:00
' cache_read_tokens ' : 0 ,
' cache_write_tokens ' : 0 ,
2026-05-19 05:51:52 +00:00
' cache_hit_percent ' : None ,
2026-05-10 12:51:46 +00:00
' context_length ' : 0 ,
' threshold_tokens ' : 0 ,
' last_prompt_tokens ' : 0 ,
}
2026-06-25 14:11:46 +00:00
_session_obj = _current_live_usage_session ( )
2026-05-10 12:51:46 +00:00
_agent = agent
if _agent is not None :
try :
_usage [ ' input_tokens ' ] = getattr ( _agent , ' session_prompt_tokens ' , 0 ) or 0
_usage [ ' output_tokens ' ] = getattr ( _agent , ' session_completion_tokens ' , 0 ) or 0
_usage [ ' estimated_cost ' ] = getattr ( _agent , ' session_estimated_cost_usd ' , 0 ) or 0
2026-05-17 02:49:34 +00:00
_usage [ ' cache_read_tokens ' ] = getattr ( _agent , ' session_cache_read_tokens ' , 0 ) or 0
_usage [ ' cache_write_tokens ' ] = getattr ( _agent , ' session_cache_write_tokens ' , 0 ) or 0
2026-05-10 12:51:46 +00:00
except Exception :
pass
try :
_cc = getattr ( _agent , ' context_compressor ' , None )
if _cc :
2026-05-31 13:17:07 +00:00
_cc_cl_u = getattr ( _cc , ' context_length ' , 0 ) or 0
2026-06-21 18:36:26 +00:00
# Stale-compressor self-heal (#3256, broadened): the
# agent-side compressor caches a context_length from the
# model it was *built/last-updated* with. After an in-place
# model switch (or when agent_init seeded it with the global
# model.context_length cap), that cached value can be the
# WRONG model's window — e.g. a session on claude-opus-4.8
# (1M / 936k prompt on Copilot) whose compressor still holds
# claude-opus-4.5's 168k. The original guard only corrected
# the narrow case where the cached value equalled the config
# cap exactly; a leftover *other-model* value (168k) slipped
# straight through to the live usage payload. Broaden it:
# ALWAYS resolve the real per-model window for the agent's
# CURRENT model and, when that differs from the cached value,
# surface the real one. Frontend hydration (GET /api/session)
# already does this; this aligns the streaming path with it
# so "refresh shows 1M, send-a-message drops to 168k" can't
# happen.
# PERF: resolve at most once per stream (cached in
# _real_ctx_cache). This snapshot runs on every metering
# tick; doing the config read + metadata lookup per tick
# froze non-default-model streams.
2026-05-31 13:27:17 +00:00
if _real_ctx_cache [ 0 ] is None :
2026-06-21 18:36:26 +00:00
_resolved_real = 0 # 0 = no correction / lookup failed
2026-05-31 13:27:17 +00:00
try :
2026-06-21 18:36:26 +00:00
_sm_u = str ( getattr ( _agent , ' model ' , ' ' ) or ' ' ) . strip ( )
_prov_u = str ( getattr ( _agent , ' provider ' , ' ' ) or ' ' ) . strip ( )
_base_u = str ( getattr ( _agent , ' base_url ' , ' ' ) or ' ' ) . strip ( )
_key_u = getattr ( _agent , ' api_key ' , ' ' ) or ' '
if _sm_u :
# Resolve the real window through the SAME helper
# hydration uses (routes._context_length_lookup_inputs_for_model
# + get_model_context_length). This honors the
# nested per-model config override
# (model.<provider>.models.<model>.context_length,
# e.g. claude-opus-4.8 -> 1,000,000) and custom-
# provider keys, so the streaming/SSE path and the
# GET /api/session path land on the IDENTICAL value.
# Reusing the helper (instead of hand-reading the
# flat top-level model.context_length, which is
# None here) is what prevents a new mismatch like
# "refresh shows 1M, send-a-message shows 936k".
2026-05-31 13:17:07 +00:00
try :
2026-06-21 18:36:26 +00:00
from api . routes import (
_context_length_lookup_inputs_for_model as _cli_u ,
_should_accept_session_context_length_refresh as _accept_u ,
)
from agent . model_metadata import get_model_context_length as _g_u
# Resolve the SESSION's own profile config, not
# the ambient one. This worker is a detached
# thread that does NOT inherit the per-request
# thread-local profile context, so a bare
# get_config() resolves the process-global
# (default) profile (#3294) — for a non-default
# profile that pins a different per-model
# context_length, that would surface the WRONG
# profile's window in the live payload. Read the
# session's profile home explicitly, mirroring
# the worker's own _cfg resolution below.
2026-05-31 13:27:17 +00:00
try :
2026-06-21 18:36:26 +00:00
from api . config import get_config_for_profile_home as _gch_u
from api . profiles import get_hermes_home_for_profile as _ghp_u
_ph_u = _ghp_u ( getattr ( _session_obj , ' profile ' , None ) )
_cfg_u = _gch_u ( _ph_u )
except Exception :
from api . config import get_config as _gc_u
_cfg_u = _gc_u ( )
_lk_u = _cli_u (
_sm_u ,
_prov_u ,
base_url = _base_u ,
api_key = _key_u ,
cfg = _cfg_u if isinstance ( _cfg_u , dict ) else { } ,
)
_real_u = _g_u (
_sm_u ,
_lk_u . base_url ,
api_key = _lk_u . api_key ,
config_context_length = _lk_u . config_context_length ,
provider = _lk_u . provider or _prov_u or ' ' ,
custom_providers = _lk_u . custom_providers ,
) or 0
# Only treat it as a correction when the real
# window is valid AND disagrees with the
# compressor's cached value. Equal => nothing
# to fix, leave the fast path untouched.
# #4248: never let a low-confidence 256k metadata
# fallback clobber a LARGER cached window — that
# would reintroduce the very "drops to a smaller
# window mid-stream" regression this guard fixes.
# Reuse the exact acceptance gate hydration uses.
# NOTE: we deliberately omit model_changed (=False
# default) here, unlike hydration. The streaming
# path can't cheaply know if the model changed
# since the compressor was seeded, so we err
# toward the LARGER window (auto-compress fires
# late, not early — the safe direction), and the
# next GET /api/session hydration self-heals any
# genuine downward 256k case via model_changed.
if (
_real_u and _real_u != _cc_cl_u
and _accept_u ( _cc_cl_u , _real_u )
) :
_resolved_real = _real_u
except TypeError :
# Older hermes-agent: legacy 2-arg form.
try :
from api . routes import (
_should_accept_session_context_length_refresh as _accept2_u ,
)
from agent . model_metadata import get_model_context_length as _g2_u
_real_u = _g2_u ( _sm_u , _base_u ) or 0
if (
_real_u and _real_u != _cc_cl_u
and _accept2_u ( _cc_cl_u , _real_u )
) :
2026-05-31 13:27:17 +00:00
_resolved_real = _real_u
except Exception :
pass
2026-06-21 18:36:26 +00:00
except Exception :
pass
2026-05-31 13:27:17 +00:00
except Exception :
_resolved_real = 0
_real_ctx_cache [ 0 ] = _resolved_real
# Apply the cached real cap when the guard determined one.
if _real_ctx_cache [ 0 ] :
2026-05-31 13:46:50 +00:00
# Also rescale threshold_tokens by the same ratio so the
# auto-compress trigger reflects the real window, not
# the stale global cap (e.g. 197.2k @ 232K cap → ~850k
# @ 1M real cap).
_orig_cc_cl = getattr ( _cc , ' context_length ' , 0 ) or 0
_orig_thresh = getattr ( _cc , ' threshold_tokens ' , 0 ) or 0
2026-05-31 13:27:17 +00:00
_cc_cl_u = _real_ctx_cache [ 0 ]
2026-05-31 13:46:50 +00:00
if _orig_cc_cl > 0 and _orig_thresh > 0 :
_scaled_thresh = int ( _orig_thresh * _real_ctx_cache [ 0 ] / _orig_cc_cl )
_usage [ ' context_length ' ] = _cc_cl_u
_usage [ ' threshold_tokens ' ] = _scaled_thresh
_usage [ ' last_prompt_tokens ' ] = getattr ( _cc , ' last_prompt_tokens ' , 0 ) or 0
else :
_usage [ ' context_length ' ] = _cc_cl_u
_usage [ ' threshold_tokens ' ] = _orig_thresh
_usage [ ' last_prompt_tokens ' ] = getattr ( _cc , ' last_prompt_tokens ' , 0 ) or 0
else :
_usage [ ' context_length ' ] = _cc_cl_u
_usage [ ' threshold_tokens ' ] = getattr ( _cc , ' threshold_tokens ' , 0 ) or 0
_usage [ ' last_prompt_tokens ' ] = getattr ( _cc , ' last_prompt_tokens ' , 0 ) or 0
2026-05-10 12:51:46 +00:00
except Exception :
pass
if _session_obj is not None :
2026-05-17 02:49:34 +00:00
for _field in ( ' input_tokens ' , ' output_tokens ' , ' estimated_cost ' , ' cache_read_tokens ' , ' cache_write_tokens ' , ' context_length ' , ' threshold_tokens ' , ' last_prompt_tokens ' ) :
2026-05-10 12:51:46 +00:00
if not _usage . get ( _field ) :
try :
_usage [ _field ] = getattr ( _session_obj , _field , 0 ) or 0
except Exception :
pass
2026-05-10 16:31:14 +00:00
_real_prompt_tokens = int ( _usage . get ( ' last_prompt_tokens ' ) or 0 )
2026-05-19 05:51:52 +00:00
_usage [ ' cache_hit_percent ' ] = prompt_cache_hit_percent (
_usage . get ( ' cache_read_tokens ' ) or 0 ,
_usage . get ( ' input_tokens ' ) or 0 ,
)
2026-05-10 16:31:14 +00:00
if _real_prompt_tokens and _real_prompt_tokens != _live_prompt_exact_tokens [ 0 ] :
_live_prompt_exact_tokens [ 0 ] = _real_prompt_tokens
_live_prompt_estimate_tokens [ 0 ] = _real_prompt_tokens
2026-05-25 07:54:01 +00:00
_live_prompt_estimate_tool_delta_tokens [ 0 ] = 0
2026-05-10 16:31:14 +00:00
elif _live_prompt_estimate_tokens [ 0 ] > _real_prompt_tokens :
2026-05-10 12:51:46 +00:00
_usage [ ' last_prompt_tokens ' ] = _live_prompt_estimate_tokens [ 0 ]
return _usage
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
# Register this stream with the global streaming meter
meter ( ) . begin_session ( stream_id )
# Metering ticker — emits a metering event at 1 Hz while sessions are active.
# When get_interval() returns >= 10.0 (no active sessions), the ticker exits
# so no idle readings are emitted and the SSE consumer sees nothing.
_metering_stop = threading . Event ( )
def _metering_ticker ( ) :
while True :
interval = meter ( ) . get_interval ( )
if interval > = 10.0 :
break # nothing active — stop the ticker
if _metering_stop . wait ( interval ) :
break # stream was cancelled or ended — exit
stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
stats [ ' session_id ' ] = session_id
2026-05-10 12:51:46 +00:00
stats [ ' usage ' ] = _live_usage_snapshot ( )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
put ( ' metering ' , stats )
_metering_thread = threading . Thread ( target = _metering_ticker , daemon = True )
_metering_thread . start ( )
2026-03-31 03:40:19 +00:00
def put ( event , data ) :
# If cancelled, drop all further events except the cancel event itself
if cancel_event . is_set ( ) and event not in ( ' cancel ' , ' error ' ) :
return
2026-06-06 19:12:37 +00:00
event_id = None
2026-05-15 01:37:54 +00:00
if run_journal is not None :
try :
stage-364: Opus-caught live SSE event_id fix (side-channel approach)
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.
Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
→ double-rendered output.
Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.
Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
tests: test_cancel_interrupt, test_sprint42, test_sprint51,
test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
other tests: test_smooth_text_fade, test_streaming_markdown,
test_streaming_race_fix. _wireSSE must NOT reset accumulators because
legacy reconnect doesn't replay events; only journal-replay does.
Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.
6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.
Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
2026-05-16 03:58:31 +00:00
journaled = run_journal . append_sse_event ( event , data )
2026-06-06 19:12:37 +00:00
# Carry the exact journal id for this queued frame. A global
# "latest event" side channel is still kept for legacy queues,
# but StreamChannel subscribers need the per-item id so a
# queued backlog cannot advance the browser cursor past an
# undelivered event.
stage-364: Opus-caught live SSE event_id fix (side-channel approach)
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.
Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
→ double-rendered output.
Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.
Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
tests: test_cancel_interrupt, test_sprint42, test_sprint51,
test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
other tests: test_smooth_text_fade, test_streaming_markdown,
test_streaming_race_fix. _wireSSE must NOT reset accumulators because
legacy reconnect doesn't replay events; only journal-replay does.
Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.
6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.
Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
2026-05-16 03:58:31 +00:00
event_id = ( journaled or { } ) . get ( ' event_id ' ) if isinstance ( journaled , dict ) else None
if event_id :
STREAM_LAST_EVENT_ID [ stream_id ] = event_id
2026-05-15 01:37:54 +00:00
except Exception :
logger . debug ( " Failed to append run journal event %s for stream %s " , event , stream_id , exc_info = True )
2026-06-06 19:12:37 +00:00
if event_id and hasattr ( q , " note_last_event_id " ) :
try :
q . note_last_event_id ( event_id )
except Exception :
logger . debug ( " Failed to note event_id %s for stream %s " , event_id , stream_id , exc_info = True )
2026-03-31 03:40:19 +00:00
try :
2026-06-06 19:12:37 +00:00
queue_item = ( event , data , event_id ) if event_id and hasattr ( q , " subscribe_with_snapshot " ) else ( event , data )
q . put_nowait ( queue_item )
2026-03-31 03:40:19 +00:00
except Exception :
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger . debug ( " Failed to put event to queue " )
2026-03-31 03:40:19 +00:00
2026-05-07 18:24:52 +00:00
def _agent_status_callback ( kind , message ) :
2026-05-18 15:44:36 +00:00
""" Bridge Agent lifecycle status into WebUI SSE.
Passes compression events as ' compressing ' events and rate - limit / fallback
events as ' warning ' events so the frontend can surface them to the user .
All other lifecycle messages are dropped silently .
"""
2026-05-07 18:24:52 +00:00
_message = str ( message or ' ' ) . strip ( )
_kind = str ( kind or ' ' ) . strip ( ) . lower ( )
if not _message :
return
_lower = _message . lower ( )
_is_compression_start = (
_kind == ' lifecycle '
and (
' preflight compression ' in _lower
or ' compressing ' in _lower
or ' compacting context ' in _lower
or ' context too large ' in _lower
)
)
2026-05-18 15:44:36 +00:00
if _is_compression_start :
put ( ' compressing ' , {
' session_id ' : session_id ,
2026-06-06 19:12:37 +00:00
' message ' : ' Compressing context ' ,
2026-05-18 15:44:36 +00:00
} )
2026-05-07 18:24:52 +00:00
return
2026-05-18 15:44:36 +00:00
# Pass through rate-limit and fallback messages so the frontend can
# show them as warnings via the existing messages.js 'warning' listener.
_is_fallback_notice = _is_fallback_lifecycle_message ( _kind , _message )
if _is_fallback_notice :
put ( ' warning ' , { ' type ' : ' fallback ' , ' message ' : _message } )
2026-05-07 18:24:52 +00:00
2026-06-09 05:36:18 +00:00
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
# finally can always reset even if an exception fires before the bind.
# Placed ABOVE the _checkpoint_stop cluster so that cluster stays adjacent
# to the `try:` (preserves the Issue #765 static-locator invariant).
_turn_session_identity_tokens = None
2026-06-22 03:38:28 +00:00
_streaming_cron_profile_home_token = None
2026-04-21 19:07:44 +00:00
# Initialised here (before any code that may raise) so the outer `finally`
# block can safely check `if _checkpoint_stop is not None` even when an
# exception fires before the checkpoint thread is created (Issue #765).
_checkpoint_stop = None
2026-04-23 21:25:43 +00:00
_ckpt_thread = None
_agent_lock = None
2026-03-31 03:40:19 +00:00
try :
2026-06-09 05:36:18 +00:00
# Bind THIS turn's session identity to the worker thread/context BEFORE
# any agent work (so every mid-turn notify_on_complete background spawn
# captures THIS session, not a concurrent turn's process-global env).
# Co-located with the existing env-restore lifecycle: set here, reset
# in the outer finally next to _clear_thread_env().
_turn_session_identity_tokens = _set_turn_session_identity ( session_id )
2026-03-31 03:40:19 +00:00
s = get_session ( session_id )
2026-05-11 00:15:00 +00:00
update_active_run ( stream_id , phase = " running " , session_id = session_id )
2026-03-31 03:40:19 +00:00
s . workspace = str ( Path ( workspace ) . expanduser ( ) . resolve ( ) )
s . model = model
2026-05-01 05:23:47 +00:00
provider_context = (
str ( model_provider ) . strip ( ) . lower ( )
if model_provider is not None
else getattr ( s , " model_provider " , None )
)
s . model_provider = provider_context or None
2026-03-31 03:40:19 +00:00
_agent_lock = _get_session_agent_lock ( session_id )
# TD1: set thread-local env context so concurrent sessions don't clobber globals
# Check for pre-flight cancel (user cancelled before agent even started)
if cancel_event . is_set ( ) :
2026-05-12 19:53:28 +00:00
with _agent_lock :
_finalize_cancelled_turn ( s , ephemeral = ephemeral , message = ' Task cancelled before start. ' )
2026-03-31 03:40:19 +00:00
put ( ' cancel ' , { ' message ' : ' Cancelled before start ' } )
return
2026-04-21 16:16:51 +00:00
# Resolve profile home for this agent run — use the session's own profile
# (stamped at new_session() time from the client's S.activeProfile) so that
# two concurrent tabs on different profiles don't clobber each other via the
# process-level active-profile global. Falls back gracefully.
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
try :
2026-05-11 11:12:02 +00:00
from api . profiles import (
2026-06-18 19:01:17 +00:00
filter_runtime_env_for_gateway_parity ,
2026-05-12 08:43:16 +00:00
patch_skill_home_modules ,
2026-05-11 11:12:02 +00:00
get_hermes_home_for_profile ,
get_profile_runtime_env ,
)
2026-04-29 06:12:59 +00:00
_profile_home_path = get_hermes_home_for_profile ( getattr ( s , ' profile ' , None ) )
_profile_home = str ( _profile_home_path )
2026-06-22 03:38:28 +00:00
_streaming_cron_profile_home_token = _STREAMING_CRON_PROFILE_HOME . set ( _profile_home )
2026-04-29 06:12:59 +00:00
_profile_runtime_env = get_profile_runtime_env ( _profile_home_path )
2026-06-18 19:01:17 +00:00
_safe_profile_runtime_env = filter_runtime_env_for_gateway_parity ( _profile_runtime_env )
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
except ImportError :
_profile_home = os . environ . get ( ' HERMES_HOME ' , ' ' )
2026-04-29 06:12:59 +00:00
_profile_runtime_env = { }
2026-06-18 19:01:17 +00:00
_safe_profile_runtime_env = { }
2026-05-12 08:43:16 +00:00
patch_skill_home_modules = None
2026-06-06 05:17:27 +00:00
# Profile-aware provider/model enrichment: when the session belongs
# to a profile that specifies model.provider and model.default, use
# those to set provider_context and repair stale models.
model , provider_context , _repaired = _apply_profile_home_context_to_streaming_model (
model = model ,
provider_context = provider_context ,
profile_home = _profile_home ,
has_profile = bool ( getattr ( s , " profile " , None ) ) ,
)
s . model_provider = provider_context
if _repaired and model != ( s . model or " " ) :
s . model = model
fix: stamp profile on continuation session after context compression
When context compression fires, the agent rotates to a new session_id.
The compression migration block correctly migrates the session lock,
SESSION_AGENT_CACHE, SESSIONS dict, and the session file rename, but
does not ensure s.profile is set on the continuation session.
On the next request, _run_agent_streaming resolves the profile via:
get_hermes_home_for_profile(getattr(s, 'profile', None))
With s.profile == None this falls back to the default profile's
HERMES_HOME. Memory tool calls then read and write the wrong profile's
MEMORY.md — confirmed by investigation: session 0dfefb (continuation
after compression from a troubleshooting profile session) read memory
at 16% / 1,184 chars with 4 entries, while the troubleshooting profile's
actual state was 72-77% / 5,000+ chars. That reading could only come
from the default profile's bank. Subsequent replace operations failed
because the target entries existed only in the troubleshooting profile.
There are two failure paths:
1. In-memory: if s.profile was None from the start (legacy session or
one created before this fix), the continuation session object carries
null through the current request.
2. Persistence: s.save() persists "profile": null to the continuation
session's JSON file (profile is in METADATA_FIELDS, models.py ~408).
On the next request, Session.load(new_sid) reads it back as null and
get_hermes_home_for_profile(None) falls back to the default profile.
Fix: capture _resolved_profile_name at request entry (~line 2019),
immediately after profile home resolution. This is the only point where
profile context is reliable: s.profile if already set, otherwise
get_active_profile_name() — which at that point reads thread-local
storage (_tls.profile) correctly set by the HTTP handler thread via
set_request_profile(). Calling get_active_profile_name() at compression
time instead would be unsafe: the streaming thread is a separate
threading.Thread, does not inherit TLS, and the call would fall back to
the process-global _active_profile which may belong to a different
concurrent tab.
Stamp s.profile in the compression migration block immediately after
s.session_id = new_sid. Guarded by `if not s.profile` so sessions that
already have a profile set are unaffected. A logger.info line records
when the stamp fires, making future investigation straightforward.
Fixes: memory writes bleeding into default profile after compression
Reproduces: reliably on any long non-default profile session that hits
the compression threshold (default: 0.80 context fill)
2026-05-10 08:57:45 +00:00
# Capture the resolved profile name now, while profile context is
# reliable. Used in the compression migration block to stamp s.profile
# on the continuation session. We resolve it here rather than calling
# get_active_profile_name() at compression time because that function
# reads thread-local storage (_tls.profile) set by set_request_profile()
# on the HTTP handler thread. The streaming thread is a separate
# threading.Thread and does not inherit TLS. At compression time,
# get_active_profile_name() would fall back to the process-global
# _active_profile, which may belong to a different concurrent tab.
_resolved_profile_name = getattr ( s , ' profile ' , None )
if not _resolved_profile_name :
try :
from api . profiles import get_active_profile_name
_resolved_profile_name = get_active_profile_name ( )
except Exception :
_resolved_profile_name = None
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
_thread_env = _build_agent_thread_env (
_profile_runtime_env ,
str ( s . workspace ) ,
session_id ,
_profile_home ,
2026-03-31 03:40:19 +00:00
)
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
_set_thread_env ( * * _thread_env )
2026-06-09 05:36:18 +00:00
# process_complete agent-wakeup wiring (ours-original, Option B): bind
# this session's HERMES_SESSION_KEY to its WebUI session_id so the
# drain thread can route notify_on_complete events back to the right
# SSE channel / server-side wakeup.
try :
from api . background_process import register_process_session
register_process_session ( session_id , session_id )
except Exception :
logger . debug ( " register_process_session failed " , exc_info = True )
2026-05-10 22:51:49 +00:00
# first-time module initialisation (which can be slow) does not
# block other concurrent sessions waiting on _ENV_LOCK (#2024).
_prewarm_skill_tool_modules ( )
2026-06-22 03:38:28 +00:00
_install_streaming_cronjob_profile_wrapper ( )
2026-03-31 03:40:19 +00:00
# Still set process-level env as fallback for tools that bypass thread-local
2026-04-08 14:22:39 +00:00
# Acquire lock only for the env mutation, then release before the agent runs.
# The finally block re-acquires to restore — keeping critical sections short
# and preventing a deadlock where the restore would re-enter the same lock.
2026-04-05 05:25:08 +00:00
with _ENV_LOCK :
2026-06-18 19:01:17 +00:00
old_profile_env = { key : os . environ . get ( key ) for key in _safe_profile_runtime_env }
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
old_cwd = os . environ . get ( ' TERMINAL_CWD ' )
old_exec_ask = os . environ . get ( ' HERMES_EXEC_ASK ' )
old_session_key = os . environ . get ( ' HERMES_SESSION_KEY ' )
2026-05-15 00:22:44 +00:00
old_session_id = os . environ . get ( ' HERMES_SESSION_ID ' )
old_session_platform = os . environ . get ( ' HERMES_SESSION_PLATFORM ' )
2026-06-09 05:36:18 +00:00
old_session_chat_id = os . environ . get ( ' HERMES_SESSION_CHAT_ID ' )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
old_hermes_home = os . environ . get ( ' HERMES_HOME ' )
2026-06-18 19:01:17 +00:00
os . environ . update ( _safe_profile_runtime_env )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
os . environ [ ' TERMINAL_CWD ' ] = str ( s . workspace )
os . environ [ ' HERMES_EXEC_ASK ' ] = ' 1 '
os . environ [ ' HERMES_SESSION_KEY ' ] = session_id
2026-05-15 00:22:44 +00:00
os . environ [ ' HERMES_SESSION_ID ' ] = session_id
os . environ [ ' HERMES_SESSION_PLATFORM ' ] = ' webui '
2026-06-09 05:36:18 +00:00
# process_complete wiring (ours-original, Option B): see
# _build_agent_thread_env above.
os . environ [ ' HERMES_SESSION_CHAT_ID ' ] = str ( session_id )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
if _profile_home :
os . environ [ ' HERMES_HOME ' ] = _profile_home
2026-06-22 03:38:28 +00:00
# Patch skill module caches to match the active profile.
2026-05-10 08:02:49 +00:00
# _set_hermes_home() does this for process-wide switches
2026-06-22 03:38:28 +00:00
# but per-request switches skip it (#1700). The in-chat
# cronjob tool is wrapped separately at its tool-call boundary
# with cron_profile_context_for_home (#4580) so cron.jobs path
# caches are not mutated for the entire agent turn.
2026-05-10 22:51:49 +00:00
# Modules were prewarmed by _prewarm_skill_tool_modules()
# above, so we only do lightweight sys.modules lookups and
# attribute assignments here — no first-time import under
# the lock (#2024).
2026-05-12 08:43:16 +00:00
if patch_skill_home_modules is not None :
patch_skill_home_modules ( Path ( _profile_home ) )
2026-04-08 14:22:39 +00:00
# Lock released — agent runs without holding it
fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation
Issue #1968: switching to a non-default profile in the WebUI dropdown
had no effect on which MCP servers were available. Every chat session,
regardless of profile, only saw the default profile's mcp_servers from
~/.hermes/config.yaml. Non-default profile MCP servers (postgres, custom
stdio servers, anything in <profile>/config.yaml) never registered.
Root cause: api/streaming.py:1922 called discover_mcp_tools() at the
TOP of _run_agent_streaming(), about 100 lines BEFORE the per-session
'os.environ["HERMES_HOME"] = _profile_home' mutation at line 2053.
discover_mcp_tools() reads ~/.hermes/config.yaml via get_hermes_home(),
which uses os.environ['HERMES_HOME']. So at the call site, HERMES_HOME
was still whatever the WebUI server process had at startup — the default
profile, every time.
Fix: relocate the discover_mcp_tools() call past the _ENV_LOCK block so
get_hermes_home() resolves to the session's actual profile home. Same
try/except wrapping is preserved; same idempotency semantics on
already-connected servers; same lazy-import pattern.
Caveat (out of scope, agent-side): _servers in tools/mcp_tool.py is a
process-global Dict[str, MCPServerTask] keyed only by server name. So
once profile A registers a server named e.g. 'postgres', profile B's
discovery sees 'postgres' as already connected and skips it — even if
B's config points at a different binary or DB. Concurrent multi-profile
WebUI processes will still hit 'first profile wins per server name'.
Fully fixing that requires keying _servers by (profile_home, name)
upstream in hermes-agent. This PR ships layer 1 only — fixes the
single-non-default-profile case (the headline symptom).
Tests: tests/test_issue1968_mcp_profile_discovery.py — 4 static tests
pinning the lexical ordering invariants. Verified mutation-safety: a
proof-of-concept revert (re-adding a discover call before the
HERMES_HOME mutation) makes the 'only called once' test fail.
Test suite: 5047 passed, 4 skipped, 3 xpassed, 0 regressions.
Closes #1968
2026-05-09 20:08:16 +00:00
# ── MCP Server Discovery (lazy import, idempotent) ──
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
# reads `~/.hermes/config.yaml` via `get_hermes_home()`, which uses
# `os.environ['HERMES_HOME']`. Calling it before the mutation always
# 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.
try :
from tools . mcp_tool import discover_mcp_tools
discover_mcp_tools ( )
except Exception :
pass # MCP not available or not configured — non-fatal
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
# Register a gateway-style notify callback so the approval system can
# push the `approval` SSE event the moment a dangerous command is
# detected, without waiting for the next on_tool() poll cycle.
# Without this, the agent thread blocks inside the terminal tool
# waiting for approval that the UI never knew to ask for, leaving
# the chat stuck in "Thinking…" forever.
_approval_registered = False
_unreg_notify = None
2026-06-13 17:49:32 +00:00
_cleanup_gateway_pending_mirror = None
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
try :
2026-06-13 11:31:03 +00:00
try :
2026-06-13 17:49:32 +00:00
from api . route_approvals import (
submit_gateway_pending_mirror as _submit_pending_for_polling ,
reconcile_gateway_pending_mirror_locked as _reconcile_gateway_pending_mirror_locked ,
_approval_sse_notify_locked as _approval_sse_notify_locked ,
_lock as _approval_lock ,
)
def _cleanup_gateway_pending_mirror ( ) :
with _approval_lock :
head , total , _changed = _reconcile_gateway_pending_mirror_locked ( session_id )
_approval_sse_notify_locked ( session_id , head , total )
2026-06-13 11:31:03 +00:00
except ImportError :
_submit_pending_for_polling = None
2026-06-13 17:49:32 +00:00
_cleanup_gateway_pending_mirror = None
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
from tools . approval import (
register_gateway_notify as _reg_notify ,
unregister_gateway_notify as _unreg_notify ,
)
def _approval_notify_cb ( approval_data ) :
2026-06-13 11:31:03 +00:00
if _submit_pending_for_polling is not None :
try :
_submit_pending_for_polling ( session_id , approval_data )
except Exception :
logger . warning ( " Failed to mirror approval into WebUI polling state " , exc_info = True )
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
put ( ' approval ' , approval_data )
_reg_notify ( session_id , _approval_notify_cb )
_approval_registered = True
except ImportError :
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger . debug ( " Approval module not available, falling back to polling " )
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
2026-04-15 05:10:50 +00:00
_clarify_registered = False
_unreg_clarify_notify = None
try :
from api . clarify import (
register_gateway_notify as _reg_clarify_notify ,
unregister_gateway_notify as _unreg_clarify_notify ,
)
def _clarify_notify_cb ( clarify_data ) :
put ( ' clarify ' , clarify_data )
_reg_clarify_notify ( session_id , _clarify_notify_cb )
_clarify_registered = True
except ImportError :
logger . debug ( " Clarify module not available, falling back to polling " )
def _clarify_callback_impl ( question , choices , sid , cancel_evt , put_event ) :
""" Bridge Hermes clarify prompts to the WebUI. """
2026-05-10 08:05:50 +00:00
timeout = _clarify_timeout_seconds ( )
2026-04-15 05:10:50 +00:00
choices_list = [ str ( choice ) for choice in ( choices or [ ] ) ]
data = {
' question ' : str ( question or ' ' ) ,
' choices_offered ' : choices_list ,
' session_id ' : sid ,
' kind ' : ' clarify ' ,
' requested_at ' : time . time ( ) ,
2026-05-10 08:05:50 +00:00
' timeout_seconds ' : timeout ,
2026-04-15 05:10:50 +00:00
}
try :
from api . clarify import submit_pending as _submit_clarify_pending , clear_pending as _clear_clarify_pending
except ImportError :
return (
" The user did not provide a response within the time limit. "
" Use your best judgement to make the choice and proceed. "
)
entry = _submit_clarify_pending ( sid , data )
deadline = time . monotonic ( ) + timeout
while True :
if cancel_evt . is_set ( ) :
_clear_clarify_pending ( sid )
return (
" The user did not provide a response within the time limit. "
" Use your best judgement to make the choice and proceed. "
)
remaining = deadline - time . monotonic ( )
if remaining < = 0 :
_clear_clarify_pending ( sid )
return (
" The user did not provide a response within the time limit. "
" Use your best judgement to make the choice and proceed. "
)
if entry . event . wait ( timeout = min ( 1.0 , remaining ) ) :
response = str ( entry . result or " " ) . strip ( )
return (
response
or " The user did not provide a response within the time limit. "
" Use your best judgement to make the choice and proceed. "
)
2026-04-08 14:22:39 +00:00
try :
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
_token_sent = False # tracks whether any streamed tokens were sent
2026-05-03 18:32:53 +00:00
_self_healed = False # (#1401) prevents infinite self-heal retries
2026-06-05 07:44:03 +00:00
# Per-message reasoning: dict maps assistant-message index → accumulated text
# (#3587) replaces the flat _reasoning_text string so each intermediate
# assistant turn (before tool calls) keeps its own reasoning segment.
_reasoning_segments : dict = { }
_current_reasoning_idx = 0
_tool_boundary_advanced = False
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
_live_tool_calls = [ ] # tool progress fallback when final messages omit tool IDs
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
2026-05-04 17:55:23 +00:00
# Throttle: emit metering events at most every 100 ms so the per-message
# TPS label feels live during fast token streams without flooding SSE.
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
_metering_last_emit = [ time . monotonic ( ) - 1 ] # fire immediately on first token
2026-06-22 22:51:56 +00:00
_reasoning_last_put = [ 0.0 ]
_reasoning_buffer = [ ' ' ]
2026-05-04 17:55:23 +00:00
_metering_output_deltas = [ 0 ]
_metering_reasoning_deltas = [ 0 ]
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
2026-06-23 02:28:52 +00:00
def _flush_reasoning_buffer ( ) :
# #4729: emit any coalesced-but-not-yet-flushed reasoning text immediately.
# The ~10 Hz throttle in on_reasoning leaves a sub-100ms tail in the buffer;
# the agent never calls reasoning_callback(None), and reasoning can transition
# to tool calls / visible output, so we must flush at every boundary that
# closes or reorders the live reasoning stream — otherwise the tail is
# silently lost from the live Thinking view (the frontend appends deltas).
if _reasoning_buffer [ 0 ] :
put ( ' reasoning ' , { ' text ' : _reasoning_buffer [ 0 ] } )
_reasoning_buffer [ 0 ] = ' '
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
def _emit_metering ( ) :
now = time . monotonic ( )
if now - _metering_last_emit [ 0 ] < 0.1 :
return
_metering_last_emit [ 0 ] = now
stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
stats [ ' session_id ' ] = session_id
stats [ ' usage ' ] = _live_usage_snapshot ( )
2026-05-04 17:55:23 +00:00
stats . setdefault ( ' tps_available ' , False )
stats . setdefault ( ' estimated ' , False )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
put ( ' metering ' , stats )
2026-05-25 04:11:24 +00:00
def _is_visible_output_echo ( text : str ) - > bool :
candidate = _compact_for_echo_compare ( text )
if not candidate :
return False
2026-06-24 07:28:09 +00:00
visible_output = STREAM_PARTIAL_TEXT . get ( stream_id , ' ' )
2026-05-25 04:11:24 +00:00
visible_tail = _compact_for_echo_compare (
2026-06-24 07:28:09 +00:00
visible_output [ - max ( len ( str ( text ) ) * 2 , 512 ) : ]
2026-05-25 04:11:24 +00:00
)
2026-06-24 07:28:09 +00:00
if visible_tail and visible_tail . endswith ( candidate ) :
return True
# Some runtimes can report a prefix of the already-streamed final
# answer through reasoning after visible output has completed. That
# prefix is not a tail echo, so catch only substantial chunks that
# are already present in the visible assistant stream. Short text
# stays on the stricter suffix path to avoid hiding genuine
# reasoning that happens to reuse an answer phrase.
if len ( candidate ) < 80 :
return False
visible_compact = _compact_for_echo_compare ( visible_output )
return bool ( visible_compact and candidate in visible_compact )
2026-05-25 04:11:24 +00:00
2026-06-23 04:45:56 +00:00
def _strip_reasoning_output_echo ( text : str ) - > bool :
nonlocal _reasoning_segments
removed = False
if stream_id in STREAM_REASONING_TEXT :
next_text , did_remove = _strip_compact_echo_suffix (
STREAM_REASONING_TEXT . get ( stream_id , ' ' ) ,
text ,
)
if did_remove :
STREAM_REASONING_TEXT [ stream_id ] = next_text
removed = True
next_buffer , did_remove_buffer = _strip_compact_echo_suffix ( _reasoning_buffer [ 0 ] , text )
if did_remove_buffer :
_reasoning_buffer [ 0 ] = next_buffer
removed = True
for idx in ( _current_reasoning_idx , _current_reasoning_idx - 1 ) :
if idx not in _reasoning_segments :
continue
next_segment , did_remove_segment = _strip_compact_echo_suffix (
_reasoning_segments . get ( idx , ' ' ) ,
text ,
)
if not did_remove_segment :
continue
if next_segment :
_reasoning_segments [ idx ] = next_segment
else :
_reasoning_segments . pop ( idx , None )
removed = True
break
return removed
2026-03-31 03:40:19 +00:00
def on_token ( text ) :
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
nonlocal _token_sent
2026-03-31 03:40:19 +00:00
if text is None :
return # end-of-stream sentinel
2026-06-23 02:28:52 +00:00
# #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 ( )
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
_token_sent = True
2026-04-23 18:16:59 +00:00
# Accumulate partial text so cancel_stream() can persist it (#893)
if stream_id in STREAM_PARTIAL_TEXT :
STREAM_PARTIAL_TEXT [ stream_id ] + = str ( text )
2026-03-31 03:40:19 +00:00
put ( ' token ' , { ' text ' : text } )
2026-05-04 17:55:23 +00:00
# Update live throughput from stream delta callbacks, not from
# byte/character length. If a backend cannot provide live deltas,
# the frontend hides TPS rather than showing an estimate.
_metering_output_deltas [ 0 ] + = 1
meter ( ) . record_token ( stream_id , _metering_output_deltas [ 0 ] )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
_emit_metering ( )
2026-03-31 03:40:19 +00:00
2026-04-13 23:18:15 +00:00
def on_reasoning ( text ) :
2026-06-05 07:44:03 +00:00
nonlocal _reasoning_segments , _current_reasoning_idx , _tool_boundary_advanced
2026-04-13 23:18:15 +00:00
if text is None :
2026-06-22 22:51:56 +00:00
# Flush any remaining coalesced reasoning buffer so the last
# partial window is not lost when the reasoning phase ends.
2026-06-23 02:28:52 +00:00
_flush_reasoning_buffer ( )
2026-04-13 23:18:15 +00:00
return
2026-06-05 07:44:03 +00:00
_tool_boundary_advanced = False
2026-05-25 04:11:24 +00:00
reasoning_delta = str ( text )
# Some runtimes mirror user-visible progress text through the
# reasoning channel after it already streamed as normal assistant
# output. Treat that as an echo, otherwise the UI renders the
# same sentence again inside a Thinking card.
if _is_visible_output_echo ( reasoning_delta ) :
return
2026-06-05 07:44:03 +00:00
# Accumulate into the current message's segment (#3587)
_reasoning_segments [ _current_reasoning_idx ] = (
_reasoning_segments . get ( _current_reasoning_idx , ' ' ) + reasoning_delta
)
# Mirror full concatenation to shared dict so cancel_stream() can persist
# it (#1361 §A). Cancel only creates one partial message, so the flat
# concatenation is correct there.
2026-04-30 23:24:29 +00:00
if stream_id in STREAM_REASONING_TEXT :
2026-05-25 04:11:24 +00:00
STREAM_REASONING_TEXT [ stream_id ] + = reasoning_delta
2026-06-22 22:51:56 +00:00
# Accumulate into a coalescing buffer so every delta reaches the
# browser — reasoning deltas are incremental, not idempotent.
_reasoning_buffer [ 0 ] + = reasoning_delta
# Throttle reasoning SSE events to ~10 Hz to avoid overwhelming the
# frontend renderer. Each event triggers _parseStreamState() which
# scans the full accumulated text — 10k+ reasoning tokens/second
# builds up and locks the JS main thread. The user still sees live
# Thinking updates, just at a sustainable rate.
now = time . monotonic ( )
if now - _reasoning_last_put [ 0 ] > = 0.1 :
_reasoning_last_put [ 0 ] = now
put ( ' reasoning ' , { ' text ' : _reasoning_buffer [ 0 ] } )
_reasoning_buffer [ 0 ] = ' '
2026-05-04 17:55:23 +00:00
# Track reasoning deltas in the meter so live TPS reflects all AI output.
_metering_reasoning_deltas [ 0 ] + = 1
meter ( ) . record_reasoning ( stream_id , _metering_reasoning_deltas [ 0 ] )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
_emit_metering ( )
2026-04-13 23:18:15 +00:00
2026-05-08 02:28:45 +00:00
def on_interim_assistant ( text , * * cb_kwargs ) :
2026-06-05 07:44:03 +00:00
nonlocal _current_reasoning_idx
# Advance the per-message reasoning index unconditionally (#3587):
# even if this callback fires with empty text, a new assistant
# segment is starting and subsequent reasoning must be attributed
# to the next message.
_current_reasoning_idx + = 1
2026-05-08 02:28:45 +00:00
if text is None :
return
visible = str ( text ) . strip ( )
if not visible :
return
2026-06-23 04:45:56 +00:00
reasoning_echo = _strip_reasoning_output_echo ( visible )
2026-05-25 04:11:24 +00:00
already_streamed = bool ( cb_kwargs . get ( ' already_streamed ' , False ) ) or _is_visible_output_echo ( visible )
2026-06-23 04:45:56 +00:00
payload = {
2026-05-08 02:28:45 +00:00
' text ' : visible ,
2026-05-25 04:11:24 +00:00
' already_streamed ' : already_streamed ,
2026-06-23 04:45:56 +00:00
}
if reasoning_echo :
payload [ ' reasoning_echo ' ] = True
put ( ' interim_assistant ' , payload )
2026-05-08 02:28:45 +00:00
2026-04-23 21:25:43 +00:00
# Pre-initialise the activity counter here so on_tool (which
# closes over it) never captures an unbound name even if this
# block is reordered later (Issue #765).
_checkpoint_activity = [ 0 ]
2026-05-19 18:50:26 +00:00
_live_tool_event_start_ids = set ( )
_live_tool_event_complete_ids = set ( )
def _tool_args_snapshot ( args ) :
args_snap = { }
if isinstance ( args , dict ) :
for k , v in list ( args . items ( ) ) [ : 4 ] :
s2 = str ( v )
args_snap [ k ] = s2 [ : 120 ] + ( ' ... ' if len ( s2 ) > 120 else ' ' )
return args_snap
2026-04-23 21:25:43 +00:00
2026-05-10 12:51:46 +00:00
def _record_live_tool_start ( tool_call_id , name , args ) :
if not tool_call_id or tool_call_id in _live_prompt_estimate_seen_ids :
2026-05-19 18:50:26 +00:00
return False
2026-05-10 12:51:46 +00:00
_live_prompt_estimate_seen_ids . add ( tool_call_id )
_tool_call = {
' id ' : tool_call_id ,
' type ' : ' function ' ,
' function ' : {
' name ' : str ( name or ' ' ) ,
' arguments ' : json . dumps ( args if isinstance ( args , dict ) else { } , ensure_ascii = False , sort_keys = True ) ,
} ,
}
_bump_live_prompt_estimate ( [ {
' role ' : ' assistant ' ,
' content ' : ' ' ,
' tool_calls ' : [ _tool_call ] ,
} ] )
2026-05-19 18:50:26 +00:00
return True
2026-05-10 12:51:46 +00:00
def _record_live_tool_complete ( tool_call_id , name , function_result ) :
if not tool_call_id :
2026-05-19 18:50:26 +00:00
return False
2026-05-10 12:51:46 +00:00
_result_text = _tool_result_snippet ( function_result )
_bump_live_prompt_estimate ( [ {
' role ' : ' tool ' ,
' name ' : str ( name or ' ' ) ,
' tool_call_id ' : tool_call_id ,
' content ' : _result_text ,
} ] )
2026-05-19 18:50:26 +00:00
return True
2026-05-10 12:51:46 +00:00
2026-04-13 23:18:15 +00:00
def on_tool ( * cb_args , * * cb_kwargs ) :
2026-06-05 07:44:03 +00:00
nonlocal _reasoning_segments , _current_reasoning_idx , _tool_boundary_advanced
2026-06-23 02:28:52 +00:00
# #4729: a tool boundary closes/reorders the live reasoning stream — flush
# any buffered reasoning tail first so it isn't stranded behind the tool event.
_flush_reasoning_buffer ( )
2026-04-13 23:18:15 +00:00
event_type = None
name = None
preview = None
args = None
if len ( cb_args ) > = 4 :
event_type , name , preview , args = cb_args [ : 4 ]
elif len ( cb_args ) == 3 :
name , preview , args = cb_args
event_type = ' tool.started '
elif len ( cb_args ) == 2 :
event_type , name = cb_args
elif len ( cb_args ) == 1 :
name = cb_args [ 0 ]
event_type = ' tool.started '
if event_type in ( ' reasoning.available ' , ' _thinking ' ) :
reason_text = preview if event_type == ' reasoning.available ' else name
if reason_text :
2026-05-25 04:11:24 +00:00
reason_delta = str ( reason_text )
# Older tool-progress paths can mirror the same visible
# progress text already emitted through stream_delta_callback.
# Suppress those echoes like the dedicated reasoning callback.
if _is_visible_output_echo ( reason_delta ) :
return
2026-06-05 07:44:03 +00:00
# Accumulate into the current message's segment (#3587)
_reasoning_segments [ _current_reasoning_idx ] = (
_reasoning_segments . get ( _current_reasoning_idx , ' ' ) + reason_delta
)
# Mirror full concatenation to shared dict (#1361 §A)
2026-04-30 23:24:29 +00:00
if stream_id in STREAM_REASONING_TEXT :
2026-05-25 04:11:24 +00:00
STREAM_REASONING_TEXT [ stream_id ] + = reason_delta
put ( ' reasoning ' , { ' text ' : reason_delta } )
2026-05-04 17:55:23 +00:00
_metering_reasoning_deltas [ 0 ] + = 1
meter ( ) . record_reasoning ( stream_id , _metering_reasoning_deltas [ 0 ] )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
_emit_metering ( )
2026-04-13 23:18:15 +00:00
return
2026-06-05 07:44:03 +00:00
# (#3587) Advance reasoning index at tool-call boundaries.
# on_interim_assistant is suppressed for contentless tool-call
# messages (run_agent.py:3834), so the index never advances
# there. The first tool.started event after reasoning indicates
# a new assistant message boundary.
if not _tool_boundary_advanced and _current_reasoning_idx in _reasoning_segments :
_current_reasoning_idx + = 1
_tool_boundary_advanced = True
2026-05-19 18:50:26 +00:00
args_snap = _tool_args_snapshot ( args )
2026-04-13 23:18:15 +00:00
2026-05-19 22:41:08 +00:00
# Modern Hermes Agent builds can call both tool_progress_callback
# and the structured tool_start/tool_complete callbacks for the
# same tool. Prefer the structured path when it is supported so
# the browser receives one tid-tagged tool card per real call.
if event_type in ( None , ' tool.started ' ) and ' tool_start_callback ' in _agent_params :
return
2026-04-13 23:18:15 +00:00
if event_type in ( None , ' tool.started ' ) :
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
_live_tool_calls . append ( {
' name ' : name ,
' args ' : args if isinstance ( args , dict ) else { } ,
} )
2026-04-30 23:24:29 +00:00
# Mirror to shared dict so cancel_stream() can persist it (#1361 §B)
if stream_id in STREAM_LIVE_TOOL_CALLS :
STREAM_LIVE_TOOL_CALLS [ stream_id ] . append ( {
' name ' : name ,
' args ' : args if isinstance ( args , dict ) else { } ,
' done ' : False ,
} )
2026-04-13 23:18:15 +00:00
put ( ' tool ' , {
' event_type ' : event_type or ' tool.started ' ,
' name ' : name ,
' preview ' : preview ,
' args ' : args_snap ,
} )
2026-05-10 12:51:46 +00:00
_tool_stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
_tool_stats [ ' session_id ' ] = session_id
2026-05-10 12:51:46 +00:00
_tool_stats [ ' usage ' ] = _live_usage_snapshot ( )
put ( ' metering ' , _tool_stats )
2026-04-13 23:18:15 +00:00
# Fallback: poll for pending approval in case notify_cb wasn't
# registered (e.g. older approval module without gateway support).
try :
2026-06-13 17:49:32 +00:00
from api . route_approvals import (
_gateway_queues as _approval_gateway_queues ,
_lock as _approval_lock ,
_pending as _approval_pending ,
reconcile_gateway_pending_mirror_locked as _reconcile_gateway_pending_mirror_locked ,
)
2026-06-13 11:31:03 +00:00
from tools . approval import has_blocking_approval as _has_blocking_approval
if _has_blocking_approval ( session_id ) :
p = None
with _approval_lock :
2026-06-13 17:49:32 +00:00
_reconcile_gateway_pending_mirror_locked ( session_id )
2026-06-13 11:31:03 +00:00
queue = _approval_pending . get ( session_id )
if isinstance ( queue , list ) :
p = dict ( queue [ 0 ] ) if queue else None
elif queue :
p = dict ( queue )
if p is None :
gw_queue = _approval_gateway_queues . get ( session_id ) or [ ]
if gw_queue :
raw = getattr ( gw_queue [ 0 ] , ' data ' , None ) or { }
if raw :
p = dict ( raw )
2026-06-13 12:03:02 +00:00
else :
logger . warning ( " Gateway queue entry for %s has no .data attribute " , session_id )
2026-04-13 23:18:15 +00:00
if p :
put ( ' approval ' , p )
except ImportError :
pass
return
2026-05-19 22:41:08 +00:00
if event_type == ' tool.completed ' and ' tool_complete_callback ' in _agent_params :
return
2026-04-13 23:18:15 +00:00
if event_type == ' tool.completed ' :
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
for live_tc in reversed ( _live_tool_calls ) :
if live_tc . get ( ' done ' ) :
continue
if not name or live_tc . get ( ' name ' ) == name :
live_tc [ ' done ' ] = True
live_tc [ ' duration ' ] = cb_kwargs . get ( ' duration ' )
live_tc [ ' is_error ' ] = bool ( cb_kwargs . get ( ' is_error ' , False ) )
break
2026-04-30 23:24:29 +00:00
# Mirror done state to shared dict (#1361 §B)
if stream_id in STREAM_LIVE_TOOL_CALLS :
for shared_tc in reversed ( STREAM_LIVE_TOOL_CALLS [ stream_id ] ) :
if shared_tc . get ( ' done ' ) :
continue
if not name or shared_tc . get ( ' name ' ) == name :
shared_tc [ ' done ' ] = True
shared_tc [ ' duration ' ] = cb_kwargs . get ( ' duration ' )
shared_tc [ ' is_error ' ] = bool ( cb_kwargs . get ( ' is_error ' , False ) )
break
2026-04-21 19:07:44 +00:00
# Signal the checkpoint thread that new work has completed (Issue #765).
# Each completed tool call is a meaningful unit of progress worth persisting.
_checkpoint_activity [ 0 ] + = 1
2026-04-13 23:18:15 +00:00
put ( ' tool_complete ' , {
' event_type ' : event_type ,
' name ' : name ,
' preview ' : preview ,
' args ' : args_snap ,
' duration ' : cb_kwargs . get ( ' duration ' ) ,
' is_error ' : bool ( cb_kwargs . get ( ' is_error ' , False ) ) ,
} )
2026-06-04 23:46:20 +00:00
# Mirror the todo tool's in-memory state into a
# dedicated SSE event so the Todos panel can update
# in real-time without waiting for the turn to
# settle. The helper guards on name=='todo', sends
# the full snapshot (idempotent under SSE replay)
# and swallows internal errors so emission never
# breaks tool delivery. Prefer the structured
# `result` kwarg from modern Hermes builds; fall
# back to the truncated `preview` only when the
# callback was invoked without one (older builds).
#
# Graceful degradation on old builds: `preview` is a
# truncated snippet, so its JSON is usually unparseable.
# parse_todo_tool_result() then returns None and NO
# todo_state event is emitted — live panel updates are
# silently unavailable on pre-`result` builds. This is
# intended: the panel still hydrates via cold-load on the
# next session GET; it just won't update mid-stream.
emit_todo_state (
put ,
name = name ,
function_result = (
cb_kwargs . get ( ' result ' )
if cb_kwargs . get ( ' result ' ) is not None
else preview
) ,
session_id = session_id ,
stream_id = stream_id ,
)
2026-05-10 12:51:46 +00:00
_tool_stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
_tool_stats [ ' session_id ' ] = session_id
2026-05-10 12:51:46 +00:00
_tool_stats [ ' usage ' ] = _live_usage_snapshot ( )
put ( ' metering ' , _tool_stats )
2026-04-13 23:18:15 +00:00
return
2026-03-31 03:40:19 +00:00
2026-05-10 12:51:46 +00:00
def on_tool_start ( tool_call_id , name , args ) :
try :
_record_live_tool_start ( tool_call_id , name , args )
2026-05-19 18:50:26 +00:00
if tool_call_id and tool_call_id not in _live_tool_event_start_ids :
_live_tool_event_start_ids . add ( tool_call_id )
_live_tool_calls . append ( {
' name ' : name ,
' args ' : args if isinstance ( args , dict ) else { } ,
' tid ' : tool_call_id ,
} )
# Mirror to shared dict so cancel_stream() can persist it (#1361 §B)
if stream_id in STREAM_LIVE_TOOL_CALLS :
STREAM_LIVE_TOOL_CALLS [ stream_id ] . append ( {
' name ' : name ,
' args ' : args if isinstance ( args , dict ) else { } ,
' done ' : False ,
' tid ' : tool_call_id ,
} )
put ( ' tool ' , {
' event_type ' : ' tool.started ' ,
' name ' : name ,
' preview ' : None ,
' args ' : _tool_args_snapshot ( args ) ,
' tid ' : tool_call_id ,
} )
2026-05-10 12:51:46 +00:00
_tool_stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
_tool_stats [ ' session_id ' ] = session_id
2026-05-10 12:51:46 +00:00
_tool_stats [ ' usage ' ] = _live_usage_snapshot ( )
put ( ' metering ' , _tool_stats )
except Exception :
logger . debug ( ' Failed to update live prompt estimate on tool start ' , exc_info = True )
def on_tool_complete ( tool_call_id , name , args , function_result ) :
try :
_record_live_tool_complete ( tool_call_id , name , function_result )
2026-05-19 18:50:26 +00:00
if tool_call_id and tool_call_id not in _live_tool_event_complete_ids :
_live_tool_event_complete_ids . add ( tool_call_id )
result_snippet = _tool_result_snippet ( function_result )
for live_tc in reversed ( _live_tool_calls ) :
if live_tc . get ( ' done ' ) :
continue
if live_tc . get ( ' tid ' ) == tool_call_id or ( not live_tc . get ( ' tid ' ) and live_tc . get ( ' name ' ) == name ) :
live_tc [ ' done ' ] = True
live_tc [ ' snippet ' ] = result_snippet
break
if stream_id in STREAM_LIVE_TOOL_CALLS :
for shared_tc in reversed ( STREAM_LIVE_TOOL_CALLS [ stream_id ] ) :
if shared_tc . get ( ' done ' ) :
continue
if shared_tc . get ( ' tid ' ) == tool_call_id or ( not shared_tc . get ( ' tid ' ) and shared_tc . get ( ' name ' ) == name ) :
shared_tc [ ' done ' ] = True
shared_tc [ ' snippet ' ] = result_snippet
break
_checkpoint_activity [ 0 ] + = 1
put ( ' tool_complete ' , {
' event_type ' : ' tool.completed ' ,
' name ' : name ,
' preview ' : result_snippet ,
' args ' : _tool_args_snapshot ( args ) ,
' tid ' : tool_call_id ,
' is_error ' : False ,
} )
2026-06-04 23:46:20 +00:00
# Mirror the todo tool's in-memory state into
# a dedicated SSE event so the Todos panel can
# update in real-time without waiting for the
# turn to settle. See the legacy path above
# for the contract; the helper handles the
# name guard, payload shape, and swallow-all
# error policy.
emit_todo_state (
put ,
name = name ,
function_result = function_result ,
session_id = session_id ,
stream_id = stream_id ,
)
2026-05-10 12:51:46 +00:00
_tool_stats = meter ( ) . get_stats ( )
2026-05-10 14:20:37 +00:00
_tool_stats [ ' session_id ' ] = session_id
2026-05-10 12:51:46 +00:00
_tool_stats [ ' usage ' ] = _live_usage_snapshot ( )
put ( ' metering ' , _tool_stats )
except Exception :
logger . debug ( ' Failed to update live prompt estimate on tool completion ' , exc_info = True )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
_AIAgent = _get_ai_agent ( )
if _AIAgent is None :
docs(troubleshooting): bake the #1695 diagnostic flow into the error message + a new troubleshooting doc
Closes #1695.
@Patrick-81 reported the bare "AIAgent not available -- check that
hermes-agent is on sys.path" error on a symlinked install (~/Programmes/hermes-agent
linked to ~/hermes-agent). The maintainer's response — three diagnostic
commands plus `pip install -e .` in the agent dir — fixed it for them.
This PR captures both halves of that learning so the next user with the
same shape doesn't have to file a new issue:
1. **Error message diagnostic block.** New helper
`_aiagent_import_error_detail()` in api/streaming.py builds a multi-line
diagnostic when the import fails, including:
- the running Python interpreter
- HERMES_WEBUI_AGENT_DIR (set value, or "(not set)")
- sys.path entries that mention hermes/agent (or "no entries mention..."
— itself a strong diagnostic signal)
- the most-common fix (`pip install -e .` in the agent dir)
- a pointer to docs/troubleshooting.md
The original error message string is preserved as the FIRST line so
existing log scrapers and docs-search keep matching.
Helper is kept as a separate function so it stays out of the hot path
until we actually need to raise — building it on every successful import
would be wasted work.
2. **New docs/troubleshooting.md.** Symptom → Why → Diagnostic commands →
Fix → When-to-file-a-bug template, with one entry to start: the
"AIAgent not available" flow Patrick-81 walked through. Future
recurring failure modes follow the same template. Required a one-line
addition to .gitignore — docs/* is gitignored with an allowlist, and
the new file needed `!docs/troubleshooting.md` to be tracked.
3. **README link.** docs/troubleshooting.md added to the `## Docs` section
so users know where to look first.
13 regression tests in tests/test_1695_aiagent_import_error_detail.py:
9 for the helper output shape (preserves original message line, includes
running python, shows HERMES_WEBUI_AGENT_DIR set/unset both ways, includes
pip-install-e hint, points at troubleshooting doc, lists relevant sys.path
entries when present, says "no entries..." when absent, output is multi-line)
plus 4 for the docs-presence regression (file exists, has the AIAgent
section, includes pip install -e ., describes the diagnostic chain with
readlink + agent/__init__.py verification).
190 streaming/aiagent tests pass after the change. ast.parse on
api/streaming.py clean.
CI failure on prior push was due to the docs/* gitignore swallowing the
new troubleshooting.md file silently — this commit adds the allowlist
entry so the file is tracked.
2026-05-05 22:01:23 +00:00
raise ImportError ( _aiagent_import_error_detail ( ) )
2026-04-13 17:53:58 +00:00
# Initialize SessionDB so session_search works in WebUI sessions
2026-06-04 20:04:01 +00:00
_state_db_path = ( Path ( _profile_home ) / " state.db " ) if _profile_home else None
_session_db = _build_session_db_for_stream ( _state_db_path )
2026-05-01 05:23:47 +00:00
resolved_model , resolved_provider , resolved_base_url = resolve_model_provider (
model_with_provider_context ( model , provider_context )
)
2026-06-12 01:06:16 +00:00
configured_base_url = resolved_base_url
2026-04-03 20:34:52 +00:00
2026-04-06 21:10:26 +00:00
# Resolve API key via Hermes runtime provider (matches gateway behaviour).
# Pass the resolved provider so non-default providers get their own credentials.
2026-04-04 20:03:02 +00:00
resolved_api_key = None
try :
2026-05-07 00:22:20 +00:00
from api . oauth import resolve_runtime_provider_with_anthropic_env_lock
2026-04-04 20:03:02 +00:00
from hermes_cli . runtime_provider import resolve_runtime_provider
2026-05-07 00:22:20 +00:00
_rt = resolve_runtime_provider_with_anthropic_env_lock (
resolve_runtime_provider ,
requested = resolved_provider ,
2026-06-12 00:56:22 +00:00
target_model = resolved_model ,
2026-05-07 00:22:20 +00:00
)
2026-04-04 20:03:02 +00:00
resolved_api_key = _rt . get ( " api_key " )
if not resolved_provider :
resolved_provider = _rt . get ( " provider " )
2026-06-12 00:56:22 +00:00
resolved_base_url = _runtime_preferred_base_url (
2026-06-12 01:06:16 +00:00
_rt , resolved_provider , configured_base_url
2026-06-12 00:56:22 +00:00
)
2026-04-04 20:03:02 +00:00
except Exception as _e :
print ( f " [webui] WARNING: resolve_runtime_provider failed: { _e } " , flush = True )
2026-05-07 12:25:04 +00:00
# Named custom providers (custom:slug) may not be resolvable by
# hermes_cli.runtime_provider directly. Fall back to config.yaml
# custom_providers[] so WebUI can pass explicit creds/base_url.
2026-05-18 11:27:31 +00:00
resolved_provider , resolved_api_key , resolved_base_url = _resolve_custom_provider_runtime_overrides (
resolved_provider , resolved_api_key , resolved_base_url
)
2026-05-07 12:25:04 +00:00
2026-06-03 01:33:22 +00:00
# Read per-profile config at call time (not module-level snapshot).
# The streaming worker is a detached thread that does NOT inherit the
# per-request thread-local profile context, so the ambient
# get_config() would resolve the process-global (default) profile and
# leak the wrong profile's toolsets / prefill / fallback config into
# this run (issue #3294). Read the SESSION's own profile home
# explicitly so toolsets and context match the profile the session
# actually runs under.
from api . config import get_config_for_profile_home as _get_config_for_home
try :
_cfg = _get_config_for_home ( _profile_home )
except Exception :
from api . config import get_config as _get_config
_cfg = _get_config ( )
2026-05-22 18:46:17 +00:00
_prefill_context = _load_webui_prefill_context ( _cfg )
2026-05-28 18:22:13 +00:00
_prefill_messages = _prefill_messages_with_webui_context ( _prefill_context , _cfg )
Release v0.51.262 — Release ID (stage-r12) (#3617)
## Release v0.51.262 — Release ID (stage-r12)
Phase-3 light slice (no-screenshot items) — 3 PRs.
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3432 (#3532) | @franksong2702 | Normalize the recall-prefill terminal `user` turn so WebUI doesn't send adjacent `user` roles to strict chat templates (Mistral/Gemma/Jinja). `_normalize_prefill_messages_before_user_turn()` in both `streaming.py` + `gateway_chat.py`; drops only the terminal user tail, preserves assistant/system/mid-list context. **Rebased onto master** (was CONFLICTING). |
| #2558 (#3516) | @rodboev | "Reveal in file manager" now translates container workspace paths (`/workspace`) back to the host mount path for Docker deployments (traversal-safe via `safe_resolve` + sibling-prefix guard). |
### Changed
| Issue/PR | Author | Change |
|----------|--------|--------|
| (#3539) | @Lyr-GW | Completed the Chinese (Simplified) `zh` localization (MCP controls, tool-list pagination) with all interpolations preserved, and the language dropdown now applies the locale **instantly** on change. |
### Review fix absorbed (Codex)
#3539 also added an `allowed=['en','zh']` filter to the Settings language dropdown, which dropped the 9 other shipped locales (it/ja/ru/es/de/pt/ko/fr/tr) — and since save falls back to `en` when the select has no matching option, an existing user of those locales would be **silently reset to English** on a Settings save. Removed the filter (dropdown enumerates all `LOCALES` again, matching master); partially-translated locales fall back per-key to English at render. + regression test `test_issue3539_language_dropdown_all_locales.py`.
### Gate
- Full pytest suite: **7701 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (dropdown drops-locales) → fixed → **SAFE TO SHIP** (verified prefill drops only terminal user tail in both paths, Docker path-translation traversal-safe, zh interpolations preserved)
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Lyr-GW <Lyr-GW@users.noreply.github.com>
2026-06-05 00:16:31 +00:00
_prefill_messages = _normalize_prefill_messages_before_user_turn ( _prefill_messages )
2026-06-15 20:16:34 +00:00
_main_request_overrides = _main_model_request_overrides ( _cfg )
2026-05-18 13:22:00 +00:00
put ( ' context_status ' , {
' session_id ' : session_id ,
' prefill ' : _public_prefill_context_status ( _prefill_context ) ,
} )
2026-04-03 20:34:52 +00:00
2026-04-16 06:39:07 +00:00
# Per-profile toolsets — use _resolve_cli_toolsets() so MCP
# server toolsets are included, matching native CLI behaviour.
from api . config import _resolve_cli_toolsets
_toolsets = _resolve_cli_toolsets ( _cfg )
2026-04-03 20:34:52 +00:00
2026-05-01 12:42:21 +00:00
# Per-session toolset override (#493): if the session has
# enabled_toolsets set, use that instead of the global config.
try :
from api . models import Session , SESSION_DIR
_session_path = SESSION_DIR / f " { session_id } .json "
if _session_path . exists ( ) :
_session_meta = Session . load_metadata_only ( session_id )
2026-05-01 18:36:24 +00:00
# load_metadata_only returns a Session INSTANCE, not a dict.
# The previous .get('enabled_toolsets') raised AttributeError
# which was swallowed by the bare except below — the entire
# per-session toolset override silently no-op'd. Use
# getattr() to read the attribute correctly.
# (Opus pre-release advisor finding for v0.50.257.)
_override = getattr ( _session_meta , ' enabled_toolsets ' , None ) if _session_meta else None
if _override :
_toolsets = _override
2026-05-01 12:42:21 +00:00
except Exception as _ts_err :
print ( f " [webui] WARNING: failed to read per-session toolsets for { session_id } : { _ts_err } " , flush = True )
2026-05-27 00:51:49 +00:00
# Fallback model chain from profile config (e.g. for rate-limit or
# provider recovery). Match Hermes CLI/gateway semantics:
# fallback_providers entries are tried first, then legacy
# fallback_model entries are appended unless they duplicate an
# earlier provider/model/base_url route.
def _fallback_entries ( _raw ) :
if isinstance ( _raw , dict ) :
_items = [ _raw ]
elif isinstance ( _raw , list ) :
_items = _raw
else :
return [ ]
_entries = [ ]
for _entry in _items :
if not isinstance ( _entry , dict ) :
continue
_provider = str ( _entry . get ( ' provider ' ) or ' ' ) . strip ( )
_model = str ( _entry . get ( ' model ' ) or ' ' ) . strip ( )
if not _provider or not _model :
continue
_entries . append ( {
' model ' : _model ,
' provider ' : _provider ,
' base_url ' : _entry . get ( ' base_url ' ) ,
' api_key ' : _entry . get ( ' api_key ' ) ,
' key_env ' : _entry . get ( ' key_env ' ) ,
} )
return _entries
_fallback_chain = [ ]
_fallback_seen = set ( )
2026-04-30 16:18:00 +00:00
_fallback_resolved = None
2026-05-27 00:51:49 +00:00
for _fallback_key in ( ' fallback_providers ' , ' fallback_model ' ) :
for _fb_entry in _fallback_entries ( _cfg . get ( _fallback_key ) ) :
_identity = (
str ( _fb_entry . get ( ' provider ' ) or ' ' ) . strip ( ) . lower ( ) ,
str ( _fb_entry . get ( ' model ' ) or ' ' ) . strip ( ) . lower ( ) ,
str ( _fb_entry . get ( ' base_url ' ) or ' ' ) . strip ( ) . rstrip ( ' / ' ) . lower ( ) ,
)
if _identity in _fallback_seen :
continue
_fallback_seen . add ( _identity )
_fallback_chain . append ( _fb_entry )
_fallback_resolved = _fallback_chain or None
2026-04-03 20:34:52 +00:00
2026-04-20 23:23:19 +00:00
# Build kwargs defensively — guard newer params so the WebUI
# degrades gracefully when run against an older hermes-agent build.
# (fixes: TypeError: AIAgent.__init__() got an unexpected keyword
# argument 'credential_pool' — issue #772)
import inspect as _inspect
_agent_params = set ( _inspect . signature ( _AIAgent . __init__ ) . parameters )
2026-05-08 08:04:27 +00:00
# CLI-parity max-iteration budget: read config.yaml's
# agent.max_turns and pass it to AIAgent when supported. Without
# this WebUI-created agents silently use AIAgent's constructor
# default (90), so long browser-originated tasks hit the
# "maximum number of tool-calling iterations" summary path even
# after the operator raises Hermes' global turn budget.
_max_iterations_cfg = None
try :
_raw_max_iterations = None
_agent_cfg_for_iterations = _cfg . get ( ' agent ' , { } ) if isinstance ( _cfg , dict ) else { }
if isinstance ( _agent_cfg_for_iterations , dict ) :
_raw_max_iterations = _agent_cfg_for_iterations . get ( ' max_turns ' )
if _raw_max_iterations is None and isinstance ( _cfg , dict ) :
# Back-compat for older Hermes config files that used a
# root-level max_turns key.
_raw_max_iterations = _cfg . get ( ' max_turns ' )
if _raw_max_iterations is not None :
_parsed_max_iterations = int ( _raw_max_iterations )
if _parsed_max_iterations > 0 :
_max_iterations_cfg = _parsed_max_iterations
except Exception :
_max_iterations_cfg = None
2026-05-03 09:46:42 +00:00
# CLI-parity max output cap: read config.yaml's max_tokens and pass
# it to AIAgent when supported. Without this WebUI-created agents use
# provider-native output ceilings (e.g. Claude via OpenRouter can
# request 64k), which may turn an otherwise usable fallback into a
# 402 "more credits / fewer max_tokens" failure.
_max_tokens_cfg = None
try :
_raw_max_tokens = _cfg . get ( ' max_tokens ' )
if _raw_max_tokens is None :
_agent_cfg_for_tokens = _cfg . get ( ' agent ' , { } )
if isinstance ( _agent_cfg_for_tokens , dict ) :
_raw_max_tokens = _agent_cfg_for_tokens . get ( ' max_tokens ' )
if _raw_max_tokens is not None :
_parsed_max_tokens = int ( _raw_max_tokens )
if _parsed_max_tokens > 0 :
_max_tokens_cfg = _parsed_max_tokens
except Exception :
_max_tokens_cfg = None
2026-04-21 22:26:52 +00:00
# CLI-parity reasoning effort: read agent.reasoning_effort from the
# active profile's config.yaml (the same key the CLI writes via
# `/reasoning <level>`) and hand the parsed dict to AIAgent. When
# the key is absent or invalid, pass None → agent uses its default.
try :
2026-05-03 16:34:25 +00:00
_effort_cfg = _cfg . get ( ' agent ' , { } ) if isinstance ( _cfg , dict ) else { }
2026-04-21 22:26:52 +00:00
_effort_raw = _effort_cfg . get ( ' reasoning_effort ' ) if isinstance ( _effort_cfg , dict ) else None
Release v0.51.247 — Release HO (stage-q19) (#3521)
## Release v0.51.247 — Release HO (stage-q19)
Backend correctness fix.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3505 | @franksong2702 | **Reasoning effort is coerced to a level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. `openai-codex` `gpt-5` no longer gets `max` (→ `xhigh`); `o1`/`o3`/`o4` clamp to `low`/`medium`/`high`. Coercion only steps *down* (never escalates); `none`/unset preserved. The capability filter is applied across heuristic / models.dev / Copilot / LM Studio paths. |
This is the narrow, correct fix for the detection gap that #3431 tried to address by removing the chip-visibility gate (which we shelved). The chip-visibility gate is **untouched** (Codex confirmed) — `get_reasoning_status`/`_applyReasoningChip` still hide the chip for unconfirmed models.
### Review fix absorbed (Codex + self-flagged)
The first cut **dropped** a configured effort for *unrecognized* models, because capability detection returns `[]` for both "known-unsupported" and "simply-unknown" (custom providers, aggregator-rewritten ids, new releases) — that's a behavior change vs master (which sent it verbatim) and would silently disable reasoning. Fixed: an **empty** capability set now **preserves** the configured effort (provider stays the final authority; worst case = the same rejected request master already produces, i.e. no regression). Known-bad clamps return *non-empty* filtered sets, so they still degrade correctly. Nathan chose this "preserve-for-unknown" behavior. + regression test.
### Gate
- Full pytest suite: **7548 passed, 0 failed**
- ruff: CLEAN · 48 reasoning tests pass (incl. preserve-for-unknown + codex-clamp + never-escalate)
- Codex (regression): SHIP-ONLY-WITH-FIXES (unknown-model drop) → fixed → **SAFE TO SHIP**
- Verified empirically: gpt-5/codex max→xhigh, o3 max/xhigh→high, unknown high→high (preserved), none/unset preserved
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-04 02:21:26 +00:00
_effort = coerce_reasoning_effort_for_model (
_effort_raw ,
resolved_model ,
provider_id = resolved_provider ,
base_url = resolved_base_url ,
)
_reasoning_config = parse_reasoning_effort ( _effort )
2026-04-21 22:26:52 +00:00
except Exception :
_reasoning_config = None
2026-04-20 23:23:19 +00:00
_agent_kwargs = dict (
2026-04-02 05:56:34 +00:00
model = resolved_model ,
provider = resolved_provider ,
2026-04-02 06:15:04 +00:00
base_url = resolved_base_url ,
2026-04-04 20:03:02 +00:00
api_key = resolved_api_key ,
2026-04-24 18:03:42 +00:00
# Identify browser-originated sessions as WebUI so Hermes Agent
# does not inject CLI-specific terminal/output guidance.
platform = ' webui ' ,
2026-03-31 03:40:19 +00:00
quiet_mode = True ,
2026-04-03 20:34:52 +00:00
enabled_toolsets = _toolsets ,
fallback_model = _fallback_resolved ,
2026-03-31 03:40:19 +00:00
session_id = session_id ,
2026-04-13 17:53:58 +00:00
session_db = _session_db ,
2026-05-18 13:22:00 +00:00
prefill_messages = _prefill_messages ,
2026-03-31 03:40:19 +00:00
stream_delta_callback = on_token ,
2026-04-13 23:18:15 +00:00
reasoning_callback = on_reasoning ,
2026-03-31 03:40:19 +00:00
tool_progress_callback = on_tool ,
2026-04-15 05:10:50 +00:00
clarify_callback = (
lambda question , choices : _clarify_callback_impl (
question , choices , session_id , cancel_event , put
)
) ,
2026-03-31 03:40:19 +00:00
)
2026-04-21 22:26:52 +00:00
# reasoning_config has been an AIAgent param for several releases,
# but guard defensively to avoid TypeError on an older agent build.
if ' reasoning_config ' in _agent_params and _reasoning_config is not None :
_agent_kwargs [ ' reasoning_config ' ] = _reasoning_config
2026-05-18 13:22:00 +00:00
if ' prefill_messages ' not in _agent_params :
_agent_kwargs . pop ( ' prefill_messages ' , None )
2026-05-08 02:28:45 +00:00
if ' interim_assistant_callback ' in _agent_params :
_agent_kwargs [ ' interim_assistant_callback ' ] = on_interim_assistant
2026-05-10 12:51:46 +00:00
if ' tool_start_callback ' in _agent_params :
_agent_kwargs [ ' tool_start_callback ' ] = on_tool_start
if ' tool_complete_callback ' in _agent_params :
_agent_kwargs [ ' tool_complete_callback ' ] = on_tool_complete
2026-05-07 18:24:52 +00:00
if ' status_callback ' in _agent_params :
_agent_kwargs [ ' status_callback ' ] = _agent_status_callback
2026-05-08 08:04:27 +00:00
if ' max_iterations ' in _agent_params and _max_iterations_cfg is not None :
_agent_kwargs [ ' max_iterations ' ] = _max_iterations_cfg
2026-05-03 09:46:42 +00:00
if ' max_tokens ' in _agent_params and _max_tokens_cfg is not None :
_agent_kwargs [ ' max_tokens ' ] = _max_tokens_cfg
2026-06-15 20:16:34 +00:00
if ' request_overrides ' in _agent_params and _main_request_overrides :
_agent_kwargs [ ' request_overrides ' ] = _main_request_overrides
2026-04-20 23:23:19 +00:00
# Params added in newer hermes-agent — skip if not supported
if ' api_mode ' in _agent_params :
_agent_kwargs [ ' api_mode ' ] = _rt . get ( ' api_mode ' )
if ' acp_command ' in _agent_params :
_agent_kwargs [ ' acp_command ' ] = _rt . get ( ' command ' )
if ' acp_args ' in _agent_params :
_agent_kwargs [ ' acp_args ' ] = _rt . get ( ' args ' )
if ' credential_pool ' in _agent_params :
_agent_kwargs [ ' credential_pool ' ] = _rt . get ( ' credential_pool ' )
2026-04-22 20:48:52 +00:00
# Pin Honcho memory sessions to the stable WebUI session ID.
# Without this, 'per-session' Honcho strategy creates a new Honcho
# session on every streaming request because HonchoSessionManager is
# re-instantiated fresh each turn (#855).
if ' gateway_session_key ' in _agent_params :
_agent_kwargs [ ' gateway_session_key ' ] = session_id
2026-04-20 23:23:19 +00:00
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
# ── Agent cache: reuse across messages in the same session ──
# Mirrors gateway _agent_cache. Keeps _user_turn_count alive so
# injectionFrequency: "first-turn" actually suppresses after turn 1.
if ephemeral :
agent = _AIAgent ( * * _agent_kwargs )
logger . debug ( ' [webui] Created ephemeral agent for session %s ' , session_id )
else :
import hashlib as _hashlib
import json as _json
from api . config import SESSION_AGENT_CACHE , SESSION_AGENT_CACHE_LOCK
2026-05-16 18:48:20 +00:00
_credential_pool = _rt . get ( ' credential_pool ' )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
_sig_blob = _json . dumps ( [
resolved_model or ' ' ,
2026-05-16 18:48:20 +00:00
_agent_cache_api_key_sig ( resolved_api_key , _credential_pool ) ,
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
resolved_base_url or ' ' ,
resolved_provider or ' ' ,
2026-05-16 18:48:20 +00:00
_rt . get ( ' api_mode ' ) or ' ' ,
_rt . get ( ' command ' ) or ' ' ,
_rt . get ( ' args ' ) or [ ] ,
bool ( _credential_pool ) ,
2026-05-08 08:04:27 +00:00
_max_iterations_cfg or ' ' ,
2026-05-03 09:46:42 +00:00
_max_tokens_cfg or ' ' ,
_fallback_resolved or { } ,
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
sorted ( _toolsets ) if _toolsets else [ ] ,
2026-05-03 16:34:25 +00:00
_reasoning_config or { } ,
2026-06-15 20:16:34 +00:00
_main_request_overrides or { } ,
2026-05-18 13:22:00 +00:00
_public_prefill_context_status ( _prefill_context ) ,
2026-05-08 15:10:05 +00:00
# #1897: profile_home is part of the agent's identity because
# AIAgent caches `_cached_system_prompt` from `load_soul_md()`
# at construction time, sourced from HERMES_HOME. Same-session
# profile switches keep `session_id` stable, so without this
# field the cached agent silently retains the previous
# profile's SOUL.md (and any other profile-scoped context).
_profile_home or ' ' ,
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
] , sort_keys = True )
_agent_sig = _hashlib . sha256 ( _sig_blob . encode ( ) ) . hexdigest ( ) [ : 16 ]
agent = None
2026-05-30 23:22:56 +00:00
_identity_mismatch_entry = None
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
with SESSION_AGENT_CACHE_LOCK :
_cached = SESSION_AGENT_CACHE . get ( session_id )
if _cached and _cached [ 1 ] == _agent_sig :
2026-05-30 16:13:46 +00:00
_cached_agent = _cached [ 0 ]
if _cached_agent_matches_session ( _cached_agent , session_id ) :
agent = _cached_agent
SESSION_AGENT_CACHE . move_to_end ( session_id ) # LRU: mark as recently used
logger . debug ( ' [webui] Reusing cached agent for session %s ' , session_id )
else :
2026-05-30 23:22:56 +00:00
_identity_mismatch_entry = SESSION_AGENT_CACHE . pop ( session_id , None )
2026-05-30 16:13:46 +00:00
logger . warning (
' [webui] Evicted cached agent with mismatched session identity: cache_key= %s agent_session_id= %s ' ,
session_id ,
_cached_agent_session_identity ( _cached_agent ) ,
)
if agent is not None :
2026-05-17 09:30:06 +00:00
# Reopened/cache-hit sessions must register the agent
# so later lifecycle commits can find it.
try :
from api . session_lifecycle import register_agent
register_agent ( session_id , agent )
except Exception :
logger . debug ( " Lifecycle register_agent failed for cached session %s " , session_id , exc_info = True )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
2026-05-30 23:22:56 +00:00
if _identity_mismatch_entry is not None :
try :
_close_cached_agent_entry_at_session_boundary ( session_id , _identity_mismatch_entry )
except Exception :
logger . debug ( " Failed to close identity-mismatched cached agent for session %s " , session_id , exc_info = True )
2026-05-16 18:48:20 +00:00
if agent is not None :
# Refresh volatile runtime credentials selected from provider
# pools without discarding cross-turn agent/provider state.
if not _refresh_cached_agent_runtime ( agent , _agent_kwargs ) :
logger . warning (
' [webui] Cached agent runtime could not be safely refreshed; rebuilding agent for session %s ' ,
session_id ,
)
2026-05-30 23:22:56 +00:00
_stale_runtime_entry = None
2026-05-16 18:48:20 +00:00
with SESSION_AGENT_CACHE_LOCK :
2026-05-30 23:22:56 +00:00
_stale_runtime_entry = SESSION_AGENT_CACHE . pop ( session_id , None )
if _stale_runtime_entry is not None :
try :
_close_cached_agent_entry_at_session_boundary ( session_id , _stale_runtime_entry )
except Exception :
logger . debug ( " Failed to close stale-runtime cached agent for session %s " , session_id , exc_info = True )
2026-05-16 18:48:20 +00:00
agent = None
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
if agent is not None :
# Refresh per-turn callbacks — these close over request-scoped
# objects (put queue, cancel_event) that are new each request.
agent . stream_delta_callback = _agent_kwargs . get ( ' stream_delta_callback ' )
agent . tool_progress_callback = _agent_kwargs . get ( ' tool_progress_callback ' )
2026-05-10 14:20:37 +00:00
if hasattr ( agent , ' tool_start_callback ' ) :
agent . tool_start_callback = _agent_kwargs . get ( ' tool_start_callback ' )
if hasattr ( agent , ' tool_complete_callback ' ) :
agent . tool_complete_callback = _agent_kwargs . get ( ' tool_complete_callback ' )
2026-05-07 18:24:52 +00:00
if hasattr ( agent , ' status_callback ' ) :
agent . status_callback = _agent_kwargs . get ( ' status_callback ' )
2026-05-08 02:28:45 +00:00
if hasattr ( agent , ' interim_assistant_callback ' ) :
agent . interim_assistant_callback = _agent_kwargs . get ( ' interim_assistant_callback ' )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
if hasattr ( agent , ' reasoning_callback ' ) :
agent . reasoning_callback = _agent_kwargs . get ( ' reasoning_callback ' )
if hasattr ( agent , ' clarify_callback ' ) :
agent . clarify_callback = _agent_kwargs . get ( ' clarify_callback ' )
2026-05-25 00:46:48 +00:00
if ' prefill_messages ' in _agent_kwargs and hasattr ( agent , ' prefill_messages ' ) :
2026-05-18 13:22:00 +00:00
agent . prefill_messages = list ( _agent_kwargs . get ( ' prefill_messages ' ) or [ ] )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
if _session_db is not None :
2026-05-01 20:51:21 +00:00
# Close any previously held SessionDB connection before
# replacing it. Without this, each streaming request creates
# a new SessionDB whose WAL handles leak indefinitely,
# eventually causing EMFILE crashes (#streaming FD leak).
if hasattr ( agent , ' _session_db ' ) and agent . _session_db is not None :
try :
agent . _session_db . close ( )
except Exception :
pass
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
agent . _session_db = _session_db
if hasattr ( agent , ' _api_call_count ' ) :
agent . _api_call_count = 0
# Reset interrupt state from a prior cancel so the reused
# agent does not think it is still interrupted.
if hasattr ( agent , ' _interrupted ' ) :
agent . _interrupted = False
if hasattr ( agent , ' _interrupt_message ' ) :
agent . _interrupt_message = None
else :
agent = _AIAgent ( * * _agent_kwargs )
2026-05-17 09:30:06 +00:00
# Register the new agent with the memory lifecycle so
# its commit_memory_session() can be found later.
try :
from api . session_lifecycle import register_agent
register_agent ( session_id , agent )
except Exception :
logger . debug ( " Lifecycle register_agent failed for new session %s " , session_id , exc_info = True )
_evicted_items = [ ]
Release v0.51.256 — Release HX (stage-r4) (#3596)
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 19:28:06 +00:00
# Snapshot the set of session_ids with a LIVE agent worker
# BEFORE taking SESSION_AGENT_CACHE_LOCK, so LRU eviction never
# closes an agent mid-run AND we never nest ACTIVE_RUNS_LOCK
# inside SESSION_AGENT_CACHE_LOCK (avoids any lock-ordering
# deadlock). A cancel/reconnect can drop STREAMS while the
# worker is still unwinding or blocked in a provider call, so
# ACTIVE_RUNS (worker lifecycle) is the authoritative liveness
# signal, not STREAMS. (#3536 review round 2)
_active_sids = set ( )
try :
from api . config import ACTIVE_RUNS , ACTIVE_RUNS_LOCK
with ACTIVE_RUNS_LOCK :
for _entry in ( ACTIVE_RUNS or { } ) . values ( ) :
_sid = ( _entry or { } ) . get ( " session_id " )
if _sid :
_active_sids . add ( _sid )
except Exception :
_active_sids = set ( )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
with SESSION_AGENT_CACHE_LOCK :
SESSION_AGENT_CACHE [ session_id ] = ( agent , _agent_sig )
2026-04-29 09:35:12 +00:00
SESSION_AGENT_CACHE . move_to_end ( session_id ) # LRU: mark as recently used
from api . config import SESSION_AGENT_CACHE_MAX
Release v0.51.256 — Release HX (stage-r4) (#3596)
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 19:28:06 +00:00
# Evict the oldest INACTIVE entries first. Walk LRU order
# (front = oldest); skip any session with a live run. If
# every over-cap entry is active, leave the cache
# temporarily above cap rather than close a live worker's
# agent — a later insertion/finalization trims it once the
# run ends.
2026-04-29 09:35:12 +00:00
while len ( SESSION_AGENT_CACHE ) > SESSION_AGENT_CACHE_MAX :
Release v0.51.256 — Release HX (stage-r4) (#3596)
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 19:28:06 +00:00
_evictable_sid = None
for _sid in list ( SESSION_AGENT_CACHE . keys ( ) ) :
if _sid not in _active_sids :
_evictable_sid = _sid
break
if _evictable_sid is None :
break # all over-cap entries are active; defer
evicted_entry = SESSION_AGENT_CACHE . pop ( _evictable_sid )
_evicted_items . append ( ( _evictable_sid , evicted_entry ) )
2026-05-17 09:30:06 +00:00
# Commit and close evicted agents outside the cache lock so
# concurrent cache users are not blocked by provider I/O.
for _evicted_sid , _evicted_entry in _evicted_items :
try :
_evicted_agent = _evicted_entry [ 0 ] if isinstance ( _evicted_entry , tuple ) else None
2026-05-30 04:07:06 +00:00
_close_evicted_agent_at_session_boundary ( _evicted_sid , _evicted_agent )
2026-05-17 09:30:06 +00:00
except Exception :
logger . debug ( " Failed to close evicted agent for session %s " , _evicted_sid , exc_info = True )
logger . debug ( ' [webui] Evicted LRU agent from cache: %s ' , _evicted_sid )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
logger . debug ( ' [webui] Created new agent for session %s ' , session_id )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
# Store agent instance for cancel/interrupt propagation
with STREAMS_LOCK :
AGENT_INSTANCES [ stream_id ] = agent
# Check if cancel was requested during agent initialization
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS [ stream_id ] . is_set ( ) :
# Cancel arrived during agent creation - interrupt immediately
try :
agent . interrupt ( " Cancelled before start " )
except Exception :
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger . debug ( " Failed to interrupt agent before start " )
2026-05-12 19:53:28 +00:00
with _agent_lock :
_finalize_cancelled_turn ( s , ephemeral = ephemeral , message = ' Task cancelled before start. ' )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-03-31 03:40:19 +00:00
# Prepend workspace context so the agent always knows which directory
# to use for file operations, regardless of session age or AGENTS.md defaults.
2026-05-08 16:29:22 +00:00
workspace_ctx = _workspace_context_prefix ( str ( s . workspace ) )
2026-03-31 03:40:19 +00:00
workspace_system_msg = (
f " Active workspace at session start: { s . workspace } \n "
2026-05-08 16:29:22 +00:00
" Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the "
2026-03-31 03:40:19 +00:00
" workspace the user has selected in the web UI at the time they sent that message. "
" This tag is the single authoritative source of the active workspace and updates "
" with every message. It overrides any prior workspace mentioned in this system "
" prompt, memory, or conversation history. Always use the value from the most recent "
2026-05-08 16:29:22 +00:00
" [Workspace::v1: ...] tag as your default working directory for ALL file operations: "
2026-03-31 03:40:19 +00:00
" write_file, read_file, search_files, terminal workdir, and patch. "
" Never fall back to a hardcoded path when this tag is present. "
)
2026-04-06 21:10:30 +00:00
# Resolve personality prompt from config.yaml agent.personalities
# (matches hermes-agent CLI behavior — passes via ephemeral_system_prompt)
_personality_prompt = None
feat: /personality slash command with backend integration (#143)
* feat: /personality slash command with backend integration
Add /personality command to switch the agent's system prompt personality.
Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md.
Backend:
- GET /api/personalities: lists available personalities from the active
profile's personalities directory (reads first line of SOUL.md for desc)
- POST /api/personality/set: sets active personality on the session, reads
and validates the SOUL.md file exists, returns the prompt text
- streaming.py: injects personality prompt (SOUL.md content) as prefix to
the system_message when run_conversation is called
Frontend (commands.js):
- /personality with no args: lists available personalities as a local message
- /personality <name>: sets the personality with a toast confirmation
- /personality none|default|clear: removes the active personality
Session model: new 'personality' field (backward-compatible, defaults to None)
Closes #139
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: path traversal in personality name + case sensitivity
Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
in both routes.py (POST /api/personality/set) and streaming.py (system
prompt injection). Defense-in-depth: resolve().relative_to() check ensures
the path stays inside the personalities directory even if regex is bypassed.
Also: removed toLowerCase() from frontend command handler so personality
names are case-preserved (filesystem may be case-sensitive).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: /personality command — hardened, compact() fix, tests
Fixes on top of original PR:
- compact() was missing 'personality' field — UI couldn't know active
personality after page load. Added to Session.compact().
- GET /api/personalities: add symlink guard (is_symlink() skip) and
resolve() check — prevents reading SOUL.md from symlink targets
outside personalities dir.
- POST /api/personality/set: require() only checks session_id (not name)
so clearing with name='' works correctly instead of 400.
- POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to
prevent unbounded context window consumption.
- POST /api/personality/set: return personality:null (not '') when cleared.
- streaming.py: same MAX_FILE_BYTES guard before prepending to system msg.
Added tests/test_sprint28.py: 11 tests for API round-trip, listing,
symlink guard, path traversal rejection, clear, size cap, persistence.
Tests pass in isolation; full-suite run has a test-isolation interaction
with shared server state across sprint tests (tracked as follow-up).
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
_pname = getattr ( s , ' personality ' , None )
2026-04-06 21:10:30 +00:00
if _pname :
_agent_cfg = _cfg . get ( ' agent ' , { } )
_personalities = _agent_cfg . get ( ' personalities ' , { } )
if isinstance ( _personalities , dict ) and _pname in _personalities :
_pval = _personalities [ _pname ]
if isinstance ( _pval , dict ) :
_parts = [ _pval . get ( ' system_prompt ' , ' ' ) or _pval . get ( ' prompt ' , ' ' ) ]
if _pval . get ( ' tone ' ) :
_parts . append ( f ' Tone: { _pval [ " tone " ] } ' )
if _pval . get ( ' style ' ) :
_parts . append ( f ' Style: { _pval [ " style " ] } ' )
_personality_prompt = ' \n ' . join ( p for p in _parts if p )
else :
_personality_prompt = str ( _pval )
2026-05-16 15:36:30 +00:00
# Pass WebUI-only runtime guidance via ephemeral_system_prompt
# (agent's own mechanism). This preserves any selected personality
# while making long tool runs emit real user-visible interim text
# through interim_assistant_callback instead of frontend guesses.
2026-05-18 12:13:28 +00:00
agent . ephemeral_system_prompt = _webui_ephemeral_system_prompt (
_personality_prompt ,
surface_context = {
' source ' : ' webui ' ,
' session_id ' : session_id ,
' profile ' : getattr ( s , ' profile ' , None ) ,
' workspace ' : s . workspace ,
} ,
2026-06-02 19:36:25 +00:00
config_data = _cfg ,
2026-05-18 12:13:28 +00:00
)
2026-05-04 04:12:07 +00:00
_pending_started_at = getattr ( s , ' pending_started_at ' , None )
# Normal chat-start sets pending_started_at before spawning this thread;
2026-05-04 06:21:19 +00:00
# fallback to now only for recovered/legacy flows where that marker is absent
# or has been zeroed out (e.g. via a buggy migration / manual file edit).
# Truthy-check covers None, missing-attr, and 0 uniformly.
_turn_started_at = _pending_started_at if _pending_started_at else time . time ( )
2026-05-14 21:51:38 +00:00
_external_state_messages = get_state_db_session_messages ( getattr ( s , ' session_id ' , None ) )
_previous_messages = list (
reconciled_state_db_messages_for_session (
s ,
state_messages = _external_state_messages ,
) or [ ]
)
2026-05-19 14:32:36 +00:00
_previous_context_messages = _new_turn_context_from_messages (
2026-05-14 21:51:38 +00:00
reconciled_state_db_messages_for_session (
s ,
prefer_context = True ,
state_messages = _external_state_messages ,
) ,
2026-05-13 13:21:44 +00:00
msg_text ,
)
2026-05-21 17:56:43 +00:00
# Dedup before feeding to agent — merge_session_messages_append_only
# can produce duplicates when context_messages and state.db share
# messages with different timestamps.
_previous_context_messages = _deduplicate_context_messages ( _previous_context_messages )
2026-04-30 15:24:31 +00:00
_pre_compression_count = getattr (
getattr ( agent , ' context_compressor ' , None ) ,
' compression_count ' , 0 ,
)
2026-04-21 19:07:44 +00:00
# ── Periodic checkpoint during streaming (Issue #765) ──
# The agent works on an internal copy of s.messages during run_conversation()
# so we cannot watch s.messages for growth. Instead, on_tool() increments
# _checkpoint_activity[0] each time a tool call completes — that is the real
# signal that progress has been made worth persisting.
#
# What gets saved on each checkpoint:
# - s.pending_user_message (already written before run starts)
# - s.pending_started_at / s.active_stream_id (turn bookkeeping)
# On a server restart the UI will see a session with a pending message and no
# response — better than a silent loss of the entire conversation turn.
# The final s.save() at task completion handles the full session update + index.
# (_checkpoint_stop is pre-initialised at the top of the outer try.)
2026-04-23 21:25:43 +00:00
# (_checkpoint_activity is already initialised before on_tool().)
2026-04-21 19:07:44 +00:00
def _periodic_checkpoint ( ) :
last_saved_activity = 0
while not _checkpoint_stop . wait ( 15 ) :
try :
cur = _checkpoint_activity [ 0 ]
if cur > last_saved_activity :
2026-04-23 21:25:43 +00:00
with _agent_lock :
2026-05-25 13:21:11 +00:00
_save_streaming_checkpoint ( s )
2026-04-21 19:07:44 +00:00
last_saved_activity = cur
except Exception as e :
logger . debug ( " Periodic checkpoint save failed: %s " , e )
_checkpoint_stop = threading . Event ( )
2026-04-24 18:35:14 +00:00
# Persist the user message BEFORE streaming starts so it's durable even if
# the server crashes before the first checkpoint fires (every 15s).
with _agent_lock :
s . save ( touch_updated_at = True , skip_index = False )
2026-04-21 19:07:44 +00:00
_ckpt_thread = threading . Thread (
target = _periodic_checkpoint , daemon = True ,
name = f " ckpt- { session_id [ : 8 ] } " ,
)
_ckpt_thread . start ( )
2026-05-15 00:22:44 +00:00
_process_notifications = _drain_webui_process_notifications ( session_id )
_agent_msg_text = msg_text
if _process_notifications :
_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 )
2026-06-04 22:21:41 +00:00
_persistent_state_before = _persistent_state_snapshot ( _profile_home )
2026-03-31 03:40:19 +00:00
result = agent . run_conversation (
2026-04-28 15:18:51 +00:00
user_message = user_message ,
2026-04-06 21:10:30 +00:00
system_message = workspace_system_msg ,
2026-05-16 10:54:28 +00:00
conversation_history = _sanitize_messages_for_api ( _previous_context_messages , cfg = _cfg ) ,
2026-03-31 03:40:19 +00:00
task_id = session_id ,
persist_user_message = msg_text ,
)
2026-06-23 02:28:52 +00:00
# #4729: the run is done — flush any reasoning tail still in the coalescing
# buffer (the agent never calls reasoning_callback(None), and a turn can end on
# reasoning with no trailing token/tool boundary to trigger a flush) so the last
# sub-100ms window reaches the live Thinking view before the terminal done event.
_flush_reasoning_buffer ( )
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
if ephemeral :
_cleanup_ephemeral_cancelled_turn ( s )
else :
with _agent_lock :
_finalize_cancelled_turn ( s , ephemeral = False )
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-04-24 01:24:51 +00:00
# ── Ephemeral mode (/btw): deliver answer, skip persistence, cleanup ──
if ephemeral :
_answer = ' '
for _m in reversed ( result . get ( ' messages ' ) or [ ] ) :
if isinstance ( _m , dict ) and _m . get ( ' role ' ) == ' assistant ' :
_answer = str ( _m . get ( ' content ' , ' ' ) )
break
put ( ' done ' , {
' session ' : { ' session_id ' : session_id , ' messages ' : result . get ( ' messages ' , [ ] ) } ,
' usage ' : { ' input_tokens ' : 0 , ' output_tokens ' : 0 } ,
' ephemeral ' : True ,
' answer ' : _answer ,
} )
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
try :
import pathlib
pathlib . Path ( s . path ) . unlink ( missing_ok = True )
except Exception :
pass
return # skip all normal persistence for ephemeral sessions
2026-04-23 21:25:43 +00:00
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
with _agent_lock :
_finalize_cancelled_turn ( s , ephemeral = False )
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-04-23 21:25:43 +00:00
with _agent_lock :
2026-05-12 11:55:31 +00:00
if not ephemeral and not _stream_writeback_is_current ( s , stream_id ) :
2026-05-19 14:26:45 +00:00
if _stream_writeback_can_supersede_recovery_marker ( s , msg_text ) :
logger . info (
" Superseding stale recovery marker for session %s stream %s " ,
getattr ( s , ' session_id ' , session_id ) ,
stream_id ,
)
else :
logger . info (
" Skipping stale stream writeback for session %s stream %s ; active_stream_id= %s " ,
getattr ( s , ' session_id ' , session_id ) ,
stream_id ,
getattr ( s , ' active_stream_id ' , None ) ,
)
return
2026-06-08 08:24:34 +00:00
_tool_limit_reached = _agent_result_tool_limit_reached ( result )
2026-04-29 08:37:08 +00:00
_result_messages = result . get ( ' messages ' ) or _previous_context_messages
2026-06-08 08:24:34 +00:00
_result_messages = _drop_synthetic_max_iteration_summary_requests (
_result_messages ,
enabled = _tool_limit_reached ,
)
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
_finalize_cancelled_turn ( s , ephemeral = False )
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-04-29 08:37:08 +00:00
_next_context_messages = _restore_reasoning_metadata (
_previous_context_messages ,
_result_messages ,
)
2026-05-21 05:42:19 +00:00
_next_context_messages = _dedupe_replayed_context_messages (
2026-05-20 21:15:54 +00:00
_previous_context_messages ,
_next_context_messages ,
2026-06-16 17:03:37 +00:00
msg_text ,
2026-05-20 21:15:54 +00:00
)
2026-05-21 17:56:43 +00:00
s . context_messages = _deduplicate_context_messages ( _next_context_messages )
2026-04-29 08:37:08 +00:00
s . messages = _merge_display_messages_after_agent_result (
2026-04-23 21:25:43 +00:00
_previous_messages ,
2026-04-29 08:37:08 +00:00
_previous_context_messages ,
2026-05-18 16:33:06 +00:00
_restore_display_reasoning_metadata ( _previous_messages , _result_messages ) ,
2026-04-29 08:37:08 +00:00
msg_text ,
2026-06-18 22:21:10 +00:00
source = getattr ( s , ' pending_user_source ' , None ) or ' webui ' ,
2026-04-20 22:48:19 +00:00
)
2026-06-23 09:55:29 +00:00
_advance_truncation_watermark_after_commit ( s ) # #3831
2026-04-23 21:25:43 +00:00
# Strip XML tool-call blocks from assistant message content.
# DeepSeek and some other providers emit <function_calls>...</function_calls>
# in the raw response text; this must be removed before the content is
# saved to the session and displayed in the chat bubble. (#702)
for _m in s . messages :
if isinstance ( _m , dict ) and _m . get ( ' role ' ) == ' assistant ' :
_raw_content = _m . get ( ' content ' )
if isinstance ( _raw_content , str ) :
_cleaned = _strip_xml_tool_calls ( _raw_content )
if _cleaned != _raw_content :
_m [ ' content ' ] = _cleaned
elif isinstance ( _raw_content , list ) :
for _part in _raw_content :
if isinstance ( _part , dict ) and isinstance ( _part . get ( ' text ' ) , str ) :
_part [ ' text ' ] = _strip_xml_tool_calls ( _part [ ' text ' ] )
2026-06-06 05:45:09 +00:00
# ── Handle context compression side effects ──
# If compression fired inside run_conversation, the agent may have
# rotated its session_id. Detect and fix the mismatch before any
# terminal-failure return so snapshot preservation, continuation
# registration, and subsequent error persistence all target the
# continuation session instead of the stale parent.
#
# Lock migration: when session_id rotates, we alias the new ID to
# the *same* Lock object under SESSION_AGENT_LOCKS so that
# subsequent callers using _get_session_agent_lock(new_sid) get the
# same Lock the streaming thread is already holding. We then pop
# the old-id entry to prevent a leak. This is safe because we
# already hold _agent_lock (the Lock object itself), so the
# reference stays alive even after the dict entry is removed.
# Concurrent readers that already looked up the old ID will still
# see the same Lock object until they release it.
_compression_origin_session_id = session_id
_compression_continuation_session_id = None
_agent_sid = getattr ( agent , ' session_id ' , None )
_compressed = False
if _agent_sid and _agent_sid != session_id :
old_sid = session_id
new_sid = _agent_sid
_compression_origin_session_id = old_sid
_compression_continuation_session_id = new_sid
s . session_id = new_sid
# Carry profile identity across the compression boundary.
# Without this, s.profile stays None on the continuation
# session. On the next request, _run_agent_streaming calls
# get_hermes_home_for_profile(getattr(s, 'profile', None))
# which falls back to the default profile's HERMES_HOME.
# Memory writes then land in the wrong profile's MEMORY.md.
# Stamping here also ensures s.save() persists a non-null
# profile field to the continuation session's JSON file,
# covering the case where the session is later evicted from
# SESSIONS and reconstructed from disk via Session.load().
if not s . profile and _resolved_profile_name :
s . profile = _resolved_profile_name
logger . info (
" Stamped profile= %r on continuation session %s after compression " ,
_resolved_profile_name , new_sid ,
)
# Preserve the original session file so the full pre-compression
# history survives even when summarisation fails. The previous
# implementation renamed old_sid.json → new_sid.json, which
# destroyed the only persistent copy of the uncompressed history
# before the new (possibly summary-only) session had been saved.
# If the LLM summariser also failed, the user was left with zero
# recoverable messages. (#2223)
# ---
# Archive the old session: write its current state to disk so
# the full conversation history survives even when context
# compression removes messages from the model's context. Skip
# the write when the file already contains up-to-date data
# (i.e. it was just saved by a checkpoint).
_preserve_pre_compression_snapshot ( s , old_sid )
# The continuation is the live/tip session, not another archived
# snapshot. If the in-memory object was itself loaded from a
# pre-compression snapshot (possible on repeated compression chains
# or stale-cache repair paths), _preserve_pre_compression_snapshot()
# intentionally restores that old flag; clear it before saving the
# new continuation so sidebar/discoverability code does not hide the
# session that owns the completed turn.
s . pre_compression_snapshot = False
# Always link the continuation session to its immediate predecessor
# (the preserved snapshot). This OVERRIDES any prior
# parent_session_id because the new continuation IS the next link
# in the chain: traversal walks new → old → old.parent → ... root.
# Stage-353 Opus SHOULD-FIX: previous `if not s.parent_session_id`
# guard skipped this stamp on fork-of-fork compressions, so a
# subsequent traversal from the new continuation would jump
# over the just-preserved snapshot back to the original fork
# parent, losing access to the recoverable history in old_sid.json.
s . parent_session_id = old_sid
with LOCK :
cached_old_session = SESSIONS . pop ( old_sid , None )
if cached_old_session is not None and cached_old_session is not s :
cached_old_sid = str ( getattr ( cached_old_session , ' session_id ' , ' ' ) or ' ' )
if cached_old_sid == str ( old_sid ) :
SESSIONS [ old_sid ] = cached_old_session
else :
logger . warning (
" compression cache migration skipped stale object: old_sid= %s new_sid= %s cached_session_id= %s " ,
old_sid ,
new_sid ,
cached_old_sid or None ,
)
SESSIONS [ new_sid ] = s
SESSIONS . move_to_end ( new_sid )
while len ( SESSIONS ) > SESSIONS_MAX :
SESSIONS . popitem ( last = False )
# Migrate the per-session lock: alias new_sid to the held
# _agent_lock reference directly (not via old_sid lookup),
# then remove the old_sid entry to prevent a leak.
with SESSION_AGENT_LOCKS_LOCK :
SESSION_AGENT_LOCKS [ new_sid ] = _agent_lock
SESSION_AGENT_LOCKS . pop ( old_sid , None )
# Migrate cached agent to the new session ID so the turn
# count survives context compression.
from api . config import SESSION_AGENT_CACHE , SESSION_AGENT_CACHE_LOCK
_skipped_agent_migration_entry = None
with SESSION_AGENT_CACHE_LOCK :
_cached_entry = SESSION_AGENT_CACHE . pop ( old_sid , None )
if _cached_entry :
_cached_agent = _cached_entry [ 0 ]
if _cached_agent_matches_session ( _cached_agent , new_sid ) :
SESSION_AGENT_CACHE [ new_sid ] = _cached_entry
else :
_skipped_agent_migration_entry = _cached_entry
logger . warning (
' [webui] Skipped cached agent migration with mismatched session identity: old_sid= %s new_sid= %s agent_session_id= %s ' ,
old_sid ,
new_sid ,
_cached_agent_session_identity ( _cached_agent ) ,
)
if _skipped_agent_migration_entry is not None :
try :
_close_cached_agent_entry_at_session_boundary ( old_sid , _skipped_agent_migration_entry )
except Exception :
logger . debug ( " Failed to close skipped compression-migration cached agent for session %s " , old_sid , exc_info = True )
_compressed = True
2026-04-23 21:25:43 +00:00
# ── Detect silent agent failure (no assistant reply produced) ──
# When the agent catches an auth/network error internally it may return
# an empty final_response without raising — the stream would end with
# a done event containing zero assistant messages, leaving the user with
# no feedback. Emit an apperror so the client shows an inline error.
2026-05-17 15:39:12 +00:00
# Keep the current-turn assistant detection aligned with the
# display-merge logic. A compacted or replayed result payload
# is not always a simple append-only suffix, so use the
# workspace-aware helper from this branch while still
# preserving the pre-turn length for downstream self-heal
# checks introduced on master.
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
_all_result_messages = result . get ( ' messages ' ) or [ ]
_prev_len = len ( _previous_context_messages )
2026-05-12 14:38:29 +00:00
_assistant_added = _assistant_reply_added_after_current_turn (
2026-05-17 15:39:12 +00:00
_all_result_messages ,
2026-05-12 14:38:29 +00:00
_previous_context_messages ,
msg_text ,
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
)
2026-06-06 05:45:09 +00:00
_terminal_failure = (
_agent_result_terminal_failure ( result )
2026-06-08 08:24:34 +00:00
or (
_tool_limit_reached
and _session_lacks_final_assistant_answer ( _all_result_messages )
)
2026-06-06 05:45:09 +00:00
or (
not _token_sent
and _session_lacks_final_assistant_answer ( _all_result_messages )
)
)
if _terminal_failure :
_assistant_added = False
2026-06-12 02:25:18 +00:00
elif _tool_limit_reached and not _session_lacks_final_assistant_answer ( s . messages ) :
_mark_latest_assistant_tool_limit_status ( s . messages )
2026-04-23 21:25:43 +00:00
# _token_sent tracks whether on_token() was called (any streamed text)
2026-06-06 05:45:09 +00:00
if _terminal_failure or ( not _assistant_added and not _token_sent ) :
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
_finalize_cancelled_turn ( s , ephemeral = ephemeral )
if not ephemeral :
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-04-23 21:25:43 +00:00
_last_err = getattr ( agent , ' _last_error ' , None ) or result . get ( ' error ' ) or ' '
_err_str = str ( _last_err ) if _last_err else ' '
2026-05-07 01:15:53 +00:00
_classification = _classify_provider_error (
_err_str ,
_last_err ,
silent_failure = not bool ( _err_str ) ,
2026-04-23 21:25:43 +00:00
)
2026-05-07 01:15:53 +00:00
_is_quota = _classification [ ' type ' ] == ' quota_exhausted '
_is_auth = _classification [ ' type ' ] == ' auth_mismatch '
2026-04-23 21:25:43 +00:00
if _is_quota :
2026-05-07 01:15:53 +00:00
_err_label = _classification [ ' label ' ]
_err_type = _classification [ ' type ' ]
_err_hint = _classification [ ' hint ' ]
2026-05-03 18:32:53 +00:00
elif _is_auth and not _self_healed :
# ── Credential self-heal on 401 (#1401) ──
# Before emitting the error, try re-reading credentials
# and retrying once with a fresh agent.
_heal_result = None
_heal_rt = _attempt_credential_self_heal (
resolved_provider or ' ' , session_id , _agent_lock ,
2026-06-12 01:08:24 +00:00
target_model = resolved_model ,
2026-05-03 18:32:53 +00:00
)
if _heal_rt is not None :
logger . info ( ' [webui] self-heal: retrying stream after credential refresh ' )
# Rebuild runtime variables from the refreshed resolve
_rt = _heal_rt
resolved_api_key = _heal_rt . get ( ' api_key ' )
if not resolved_provider :
resolved_provider = _heal_rt . get ( ' provider ' )
2026-06-12 00:56:22 +00:00
resolved_base_url = _runtime_preferred_base_url (
2026-06-12 01:06:16 +00:00
_heal_rt , resolved_provider , configured_base_url
2026-06-12 00:56:22 +00:00
)
2026-05-18 11:27:31 +00:00
resolved_provider , resolved_api_key , resolved_base_url = _resolve_custom_provider_runtime_overrides (
resolved_provider , resolved_api_key , resolved_base_url
)
2026-05-03 18:32:53 +00:00
# Rebuild agent kwargs and create a fresh agent
_agent_kwargs [ ' api_key ' ] = resolved_api_key
_agent_kwargs [ ' base_url ' ] = resolved_base_url
_agent_kwargs [ ' model ' ] = resolved_model
_agent_kwargs [ ' provider ' ] = resolved_provider
2026-06-04 20:04:01 +00:00
_replace_session_db_in_kwargs ( _agent_kwargs , _state_db_path )
2026-05-03 18:32:53 +00:00
if ' credential_pool ' in _agent_params :
_agent_kwargs [ ' credential_pool ' ] = _heal_rt . get ( ' credential_pool ' )
agent = _AIAgent ( * * _agent_kwargs )
with STREAMS_LOCK :
AGENT_INSTANCES [ stream_id ] = agent
from api . config import SESSION_AGENT_CACHE as _SAC , SESSION_AGENT_CACHE_LOCK as _SAC_L
with _SAC_L :
_SAC [ session_id ] = ( agent , _agent_sig )
_SAC . move_to_end ( session_id )
# Retry the conversation once with fresh credentials
_self_healed = True
_token_sent = False
try :
_heal_result = agent . run_conversation (
user_message = user_message ,
system_message = workspace_system_msg ,
2026-05-16 10:54:28 +00:00
conversation_history = _sanitize_messages_for_api ( _previous_context_messages , cfg = _cfg ) ,
2026-05-03 18:32:53 +00:00
task_id = session_id ,
persist_user_message = msg_text ,
)
fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.
Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.
Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 06:34:19 +00:00
_heal_all_msgs = _heal_result . get ( ' messages ' ) or [ ]
_heal_ok = _has_new_assistant_reply ( _heal_all_msgs , _prev_len ) or _token_sent
2026-05-03 18:32:53 +00:00
except Exception as _retry_exc :
logger . warning (
' [webui] self-heal: retry also failed: %s ' , _retry_exc ,
)
_heal_ok = False
if _heal_ok and _heal_result is not None :
# Retry succeeded — replace result and skip error
result = _heal_result
# Fall through past the error-emission block;
# the post-result persistence code below will
# process ``result`` normally. We jump past
# the ``put('apperror', ...)`` + ``return`` by
# NOT entering the ``if not _assistant_added``
# guard again — but we are already inside it.
# Solution: set _assistant_added so the guard
# evaluates False on next conceptual pass.
# Since we're in a flat block, directly run the
# post-result merge logic here.
_result_messages = result . get ( ' messages ' ) or _previous_context_messages
2026-06-08 08:24:34 +00:00
_result_messages = _drop_synthetic_max_iteration_summary_requests (
_result_messages ,
enabled = _agent_result_tool_limit_reached ( result ) ,
)
2026-05-03 18:32:53 +00:00
_next_context_messages = _restore_reasoning_metadata (
_previous_context_messages ,
_result_messages ,
)
2026-05-21 05:42:19 +00:00
_next_context_messages = _dedupe_replayed_context_messages (
2026-05-20 21:15:54 +00:00
_previous_context_messages ,
_next_context_messages ,
2026-06-16 17:03:37 +00:00
msg_text ,
2026-05-20 21:15:54 +00:00
)
2026-05-21 17:56:43 +00:00
s . context_messages = _deduplicate_context_messages ( _next_context_messages )
2026-05-03 18:32:53 +00:00
s . messages = _merge_display_messages_after_agent_result (
_previous_messages ,
_previous_context_messages ,
_restore_reasoning_metadata ( _previous_messages , _result_messages ) ,
msg_text ,
2026-06-18 22:21:10 +00:00
source = getattr ( s , ' pending_user_source ' , None ) or ' webui ' ,
2026-05-03 18:32:53 +00:00
)
2026-06-23 09:55:29 +00:00
_advance_truncation_watermark_after_commit ( s ) # #3831
2026-05-03 18:32:53 +00:00
# Skip the error block — jump directly to the
# normal post-result persistence path by
# leaving _assistant_added truthy (set below).
_assistant_added = True # prevent re-entering guard
if not _assistant_added :
# Self-heal didn't apply or retry failed — emit error
_err_label = ' Authentication failed '
_err_type = ' auth_mismatch '
_err_hint = (
' The selected model may not be supported by your configured provider or '
' your API key is invalid. Run `hermes model` in your terminal to '
' update credentials, then restart the WebUI. '
)
2026-04-23 21:25:43 +00:00
elif _is_auth :
_err_label = ' Authentication failed '
_err_type = ' auth_mismatch '
_err_hint = (
' The selected model may not be supported by your configured provider or '
' your API key is invalid. Run `hermes model` in your terminal to '
' update credentials, then restart the WebUI. '
)
2026-06-08 08:24:34 +00:00
elif _tool_limit_reached :
_err_label = ' Tool iteration limit reached '
_err_type = ' tool_limit_reached '
_err_hint = (
' The agent reached its configured tool iteration limit before producing '
' a final answer. Start a narrower follow-up or increase agent.max_turns. '
)
_err_str = (
' The agent reached its configured tool iteration limit before producing '
' a final answer. '
)
2026-04-23 21:25:43 +00:00
else :
2026-05-07 01:15:53 +00:00
_err_label = _classification [ ' label ' ]
_err_type = _classification [ ' type ' ]
_err_hint = _classification [ ' hint ' ]
2026-05-03 18:32:53 +00:00
# Skip error emission if credential self-heal succeeded
# (#1401) — _assistant_added is set True on successful retry.
if _assistant_added :
# Self-heal succeeded: messages are already merged into s,
# fall through to normal post-result persistence below.
2026-04-23 21:25:43 +00:00
pass
2026-05-03 18:32:53 +00:00
else :
2026-05-07 01:15:53 +00:00
_error_payload = _provider_error_payload (
_err_str or f ' { _err_label } . ' ,
_err_type ,
_err_hint ,
)
2026-05-06 20:47:58 +00:00
_materialize_pending_user_turn_before_error ( s )
2026-05-03 18:32:53 +00:00
s . active_stream_id = None
s . pending_user_message = None
s . pending_attachments = [ ]
s . pending_started_at = None
2026-06-18 22:21:10 +00:00
s . pending_user_source = None
2026-06-10 19:05:48 +00:00
try :
_snapshot_and_append_partial_on_error ( s , stream_id )
except Exception :
logger . debug ( " Failed to snapshot partials on error for %s " , stream_id , exc_info = True )
2026-05-07 01:15:53 +00:00
_error_message = {
2026-05-03 18:32:53 +00:00
' role ' : ' assistant ' ,
2026-05-07 01:15:53 +00:00
' content ' : f ' ** { _err_label } :** { _error_payload . get ( " message " ) or _err_label } \n \n * { _err_hint } * ' ,
2026-05-03 18:32:53 +00:00
' timestamp ' : int ( time . time ( ) ) ,
' _error ' : True ,
2026-05-07 01:15:53 +00:00
}
if _error_payload . get ( ' details ' ) :
_error_message [ ' provider_details ' ] = _error_payload [ ' details ' ]
2026-05-12 19:53:28 +00:00
if _err_type == ' cancelled ' :
_error_message [ ' provider_details_label ' ] = ' Cancellation details '
elif _err_type == ' interrupted ' :
_error_message [ ' provider_details_label ' ] = ' Interruption details '
2026-06-08 08:24:34 +00:00
elif _err_type == ' tool_limit_reached ' :
_error_message [ ' provider_details_label ' ] = ' Terminal state details '
2026-05-07 01:15:53 +00:00
s . messages . append ( _error_message )
2026-05-03 18:32:53 +00:00
try :
s . save ( )
except Exception :
pass
2026-06-06 05:45:09 +00:00
_error_payload [ ' session ' ] = redact_session_data (
2026-06-14 18:54:47 +00:00
_session_payload_with_full_messages ( s , tool_calls = s . tool_calls )
2026-06-06 05:45:09 +00:00
)
_error_payload [ ' session_id ' ] = s . session_id
_error_payload [ ' old_session_id ' ] = _compression_origin_session_id
if _compression_continuation_session_id is not None :
_error_payload [ ' new_session_id ' ] = _compression_continuation_session_id
_error_payload [ ' continuation_session_id ' ] = _compression_continuation_session_id
2026-06-08 08:24:34 +00:00
if _err_type == ' tool_limit_reached ' :
_error_payload [ ' terminal_state ' ] = ' tool_limit_reached '
_error_payload [ ' terminal_reason ' ] = ' max_iterations '
2026-06-06 05:45:09 +00:00
put ( ' apperror ' , _error_payload )
2026-05-07 01:15:53 +00:00
# Legacy #373 source tests and clients look for the
# no_response type; #1765 keeps that type but improves
# the catch-all label, hint, and provider details.
2026-05-03 18:32:53 +00:00
return # apperror already closes the stream on the client side
2026-04-23 21:25:43 +00:00
# ── Handle context compression side effects ──
# Also detect compression via the result dict or compressor state
if not _compressed :
_compressor = getattr ( agent , ' context_compressor ' , None )
2026-04-30 15:24:31 +00:00
if _compressor and getattr ( _compressor , ' compression_count ' , 0 ) > _pre_compression_count :
2026-04-23 21:25:43 +00:00
_compressed = True
# Notify the frontend that compression happened
if _compressed :
2026-06-06 19:12:37 +00:00
s . context_messages = _prune_context_tool_results_after_compression (
agent ,
s . context_messages ,
)
2026-05-11 12:56:30 +00:00
visible_after = visible_messages_for_anchor ( s . messages , auto_compression = True )
2026-05-25 07:16:26 +00:00
# Find the LAST [CONTEXT COMPACTION] marker in s.messages
# and count visible messages before it. This is the correct
# anchor — it points to the compression boundary regardless
# of how many turns have been added since the boundary was
# established. Using len(visible_before)-1 is fragile when
# _previous_messages doesn't include markers or when extra
# messages accumulate between compression and the done event.
_last_marker_raw_idx = None
for _mi , _m in enumerate ( s . messages ) :
if _is_context_compression_marker ( _m ) :
_last_marker_raw_idx = _mi
if _last_marker_raw_idx is not None :
_visible_before_marker = visible_messages_for_anchor (
s . messages [ : _last_marker_raw_idx ] , auto_compression = True ,
)
s . compression_anchor_visible_idx = max ( 0 , len ( _visible_before_marker ) - 1 )
logger . info (
' [ANCHOR-MARKER] session= %s marker_raw= %d vis_before= %d anchor= %d ' ,
getattr ( s , ' session_id ' , ' ? ' ) ,
_last_marker_raw_idx ,
len ( _visible_before_marker ) ,
s . compression_anchor_visible_idx ,
)
else :
# Fallback: use pre-turn display messages
visible_before = visible_messages_for_anchor (
_previous_messages , auto_compression = True ,
)
if visible_before :
s . compression_anchor_visible_idx = max ( 0 , len ( visible_before ) - 1 )
elif visible_after :
s . compression_anchor_visible_idx = 0
else :
s . compression_anchor_visible_idx = None
logger . info (
' [ANCHOR-FALLBACK] session= %s vis_before= %d anchor= %d ' ,
getattr ( s , ' session_id ' , ' ? ' ) ,
len ( visible_before ) if visible_before else 0 ,
s . compression_anchor_visible_idx if s . compression_anchor_visible_idx is not None else - 1 ,
)
# Pick anchor_msg for _compression_anchor_message_key
_anchor_vis_idx = s . compression_anchor_visible_idx
if _anchor_vis_idx is not None and visible_after and _anchor_vis_idx < len ( visible_after ) :
anchor_msg = visible_after [ _anchor_vis_idx ]
elif visible_after :
anchor_msg = visible_after [ - 1 ]
else :
anchor_msg = None
2026-05-10 08:44:09 +00:00
s . compression_anchor_message_key = (
2026-05-25 07:16:26 +00:00
_compression_anchor_message_key ( anchor_msg ) if anchor_msg else None
2026-05-10 08:44:09 +00:00
)
s . compression_anchor_summary = _compact_summary_text (
_compression_summary_from_messages ( s . messages )
or _compression_summary_from_messages ( s . context_messages )
)
2026-05-19 02:45:43 +00:00
if _compression_continuation_session_id is None :
_compression_continuation_session_id = s . session_id
2026-04-23 21:25:43 +00:00
put ( ' compressed ' , {
2026-05-19 02:45:43 +00:00
' session_id ' : _compression_origin_session_id ,
' old_session_id ' : _compression_origin_session_id ,
' new_session_id ' : _compression_continuation_session_id ,
' continuation_session_id ' : _compression_continuation_session_id ,
2026-06-06 19:12:37 +00:00
' message ' : ' Compression finished ' ,
2026-05-13 12:02:28 +00:00
' usage ' : _live_usage_snapshot ( ) ,
2026-04-23 21:25:43 +00:00
} )
2026-05-28 17:58:18 +00:00
# Stamp 'timestamp' on any messages that don't have one yet,
# preserving transcript order across compacted/reconciled batches.
_stamp_missing_message_timestamps ( s . messages )
2026-04-23 21:25:43 +00:00
# Only auto-generate title when still default; preserves user renames
if s . title == ' Untitled ' or s . title == ' New Chat ' or not s . title :
s . title = title_from ( s . messages , s . title )
_looks_default = ( s . title == ' Untitled ' or s . title == ' New Chat ' or not s . title )
_looks_provisional = _is_provisional_title ( s . title , s . messages )
_invalid_existing_title = _looks_invalid_generated_title ( s . title )
_should_bg_title = (
( _looks_default or _looks_provisional or _invalid_existing_title )
and ( not getattr ( s , ' llm_title_generated ' , False ) or _invalid_existing_title )
)
_u0 = ' '
_a0 = ' '
if _should_bg_title :
_u0 , _a0 = _first_exchange_snippets ( s . messages )
2026-05-08 15:37:10 +00:00
# Read token/cost usage from the agent object (if available).
# Per-turn overwrite (#1857): replace cumulative session totals with the
# agent's most recent values, which already represent the current turn's
# full prompt+completion (input_tokens are the entire context, not delta).
# Defensive: only overwrite when the agent reports non-zero / non-None
# values. A rebuilt-from-cache-miss agent (post-restart, post-LRU-eviction)
# starts at zero; without this guard, the next turn would zero out the
# persisted disk total before any new tokens were spent. Per Opus advisor
# on stage-320: prevents restart-induced regression of session usage data.
2026-04-23 21:25:43 +00:00
input_tokens = getattr ( agent , ' session_prompt_tokens ' , 0 ) or 0
output_tokens = getattr ( agent , ' session_completion_tokens ' , 0 ) or 0
estimated_cost = getattr ( agent , ' session_estimated_cost_usd ' , None )
2026-05-17 02:49:34 +00:00
cache_read_tokens = getattr ( agent , ' session_cache_read_tokens ' , 0 ) or 0
cache_write_tokens = getattr ( agent , ' session_cache_write_tokens ' , 0 ) or 0
2026-05-19 05:51:52 +00:00
prev_input_tokens = getattr ( s , ' input_tokens ' , 0 ) or 0
prev_cache_read_tokens = getattr ( s , ' cache_read_tokens ' , 0 ) or 0
turn_input_tokens = max ( 0 , input_tokens - prev_input_tokens )
turn_cache_read_tokens = max ( 0 , cache_read_tokens - prev_cache_read_tokens )
# Per-turn percent is computed server-side from persisted session
# counters so the message label uses the same denominator as the
# final usage payload even if the browser missed an intermediate event.
cache_hit_percent = prompt_cache_hit_percent ( cache_read_tokens , input_tokens )
turn_cache_hit_percent = prompt_cache_hit_percent ( turn_cache_read_tokens , turn_input_tokens )
2026-05-08 15:37:10 +00:00
if input_tokens > 0 :
s . input_tokens = input_tokens
if output_tokens > 0 :
s . output_tokens = output_tokens
if estimated_cost is not None :
s . estimated_cost = estimated_cost
2026-05-17 02:49:34 +00:00
if cache_read_tokens > 0 :
s . cache_read_tokens = cache_read_tokens
if cache_write_tokens > 0 :
s . cache_write_tokens = cache_write_tokens
2026-04-23 21:25:43 +00:00
# Persist tool-call summaries even when the final message history only
# kept bare tool rows and omitted explicit assistant tool_call IDs.
tool_calls = _extract_tool_calls_from_messages (
s . messages ,
live_tool_calls = _live_tool_calls ,
)
s . tool_calls = tool_calls
2026-04-20 22:48:19 +00:00
s . active_stream_id = None
s . pending_user_message = None
s . pending_attachments = [ ]
s . pending_started_at = None
2026-06-18 22:21:10 +00:00
s . pending_user_source = None
2026-04-23 21:25:43 +00:00
# Tag the matching user message with attachment filenames for display on reload
# Only tag a user message whose content relates to this turn's text
# (msg_text is the full message including the [Attached files: ...] suffix)
if attachments :
2026-04-28 15:18:51 +00:00
display_attachments = [ _attachment_name ( a ) for a in attachments if _attachment_name ( a ) ]
2026-04-23 21:25:43 +00:00
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 :
2026-04-28 15:18:51 +00:00
m [ ' attachments ' ] = display_attachments
2026-04-23 21:25:43 +00:00
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
# is lost when the user reloads immediately after a response.
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
#
Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 00:28:05 +00:00
# #3455/#3599: split inline thinking blocks out of the saved
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
# assistant content into m['reasoning'] (server-side twin of the JS
# _splitThinkFromContent). Inline-thinking providers (e.g. MiniMax-M3)
# otherwise leave the thinking trace in m['content'], bloating the
# persisted session file 30-50% and bypassing the thinking card. The
2026-06-05 07:44:03 +00:00
# #3587: use per-message segments so intermediate assistant turns
# (before tool calls) each receive their own reasoning trace rather
# than all reasoning being written only to the last assistant message.
# Scope the walk to this turn's newly-appended assistant messages
# to prevent cross-turn reasoning clobber (multi-turn off-by-N).
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
if s . messages :
2026-06-05 07:44:03 +00:00
_prev_asst = sum (
1 for m in ( _previous_messages or [ ] )
if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant '
)
_asst_count = 0
for _rm in s . messages :
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
if not ( isinstance ( _rm , dict ) and _rm . get ( ' role ' ) == ' assistant ' ) :
continue
2026-06-05 07:44:03 +00:00
_turn_idx = _asst_count
_asst_count + = 1
if _turn_idx < _prev_asst :
continue # prior-turn message — never touch its reasoning
_seg_reasoning = _reasoning_segments . get ( _turn_idx - _prev_asst , ' ' )
_existing_reasoning = _seg_reasoning or _rm . get ( ' reasoning ' ) or ' '
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
_content = _rm . get ( ' content ' )
if isinstance ( _content , str ) and _content :
_new_content , _merged_reasoning = _split_thinking_from_content (
_content , _existing_reasoning
)
_rm [ ' content ' ] = _new_content
if _merged_reasoning :
_rm [ ' reasoning ' ] = _merged_reasoning
elif _existing_reasoning :
_rm [ ' reasoning ' ] = _existing_reasoning
2026-05-04 03:14:49 +00:00
try :
_turn_duration_seconds = max ( 0.0 , time . time ( ) - float ( _turn_started_at ) )
except Exception :
_turn_duration_seconds = 0.0
2026-05-04 17:55:23 +00:00
_turn_tps = None
if output_tokens and _turn_duration_seconds > 0 :
_turn_tps = round ( float ( output_tokens ) / _turn_duration_seconds , 1 )
2026-05-05 00:08:23 +00:00
_gateway_routing = _extract_gateway_routing_metadata (
agent ,
result ,
requested_model = resolved_model or model ,
requested_provider = resolved_provider ,
)
if _gateway_routing :
s . gateway_routing = _gateway_routing
_history = list ( getattr ( s , ' gateway_routing_history ' , None ) or [ ] )
_history . append ( _gateway_routing )
s . gateway_routing_history = _history [ - 50 : ]
2026-05-04 03:14:49 +00:00
if s . messages :
for _dm in reversed ( s . messages ) :
if isinstance ( _dm , dict ) and _dm . get ( ' role ' ) == ' assistant ' :
_dm [ ' _turnDuration ' ] = round ( _turn_duration_seconds , 3 )
2026-05-04 17:55:23 +00:00
if _turn_tps is not None :
_dm [ ' _turnTps ' ] = _turn_tps
2026-05-05 00:08:23 +00:00
if _gateway_routing :
_dm [ ' _gatewayRouting ' ] = _gateway_routing
2026-05-04 03:14:49 +00:00
break
2026-04-30 16:38:55 +00:00
# Persist context window data on the session so the context-ring
# indicator survives a page reload (#1318). Must run BEFORE
# s.save() for the same reason as the reasoning trace above.
# The fields are captured into the SSE usage payload below; this
# block writes them to the session itself so GET /api/session
# returns them on reload instead of falling back to 0.
_cc_for_save = getattr ( agent , ' context_compressor ' , None )
2026-06-01 00:14:13 +00:00
# Initialized before the compressor block so the #3256/#3263
# threshold-rescale below is safe even when there is no
# compressor (fresh agent / interrupted stream): _skip_cc_cl
# stays False and _cc_cl stays 0, so the rescale is a no-op.
_skip_cc_cl = False
_cc_cl = 0
2026-04-30 16:38:55 +00:00
if _cc_for_save :
2026-05-31 13:17:07 +00:00
_cc_cl = getattr ( _cc_for_save , ' context_length ' , 0 ) or 0
# Same guard as routes._resolve_context_length_for_session_model:
# the agent-side context_compressor was constructed with the
# global model.context_length applied to EVERY model. If the
# session's model isn't model.default, that value is a stale
# cap (e.g. 232K) that would clobber the real 1M metadata
# on every stream end. In that case skip the compressor
# value and let the fallback resolver below recompute.
2026-06-21 18:36:26 +00:00
# #4618: broaden the stale-compressor guard the same way the
# live-usage snapshot does. The OLD test only skipped the
# compressor value when it equalled the config cap EXACTLY
# (a non-default model carrying the global cap). But a
# compressor can hold a DIFFERENT model's window after an
# in-place model switch (e.g. opus-4.5's 168k lingering on an
# opus-4.8 1M session) — that value != the config cap, so the
# old guard let it persist to s.context_length and the SSE
# payload, snapping the indicator back to 168k at turn-end.
# Resolve the real per-model window via the SAME helper the
# live path + hydration use and skip the compressor value
# whenever the real window differs, honoring the #4248
# acceptance gate (never let a low-confidence 256k fallback
# clobber a larger cached window).
2026-05-31 13:17:07 +00:00
_skip_cc_cl = False
try :
2026-06-21 18:36:26 +00:00
from api . routes import (
_context_length_lookup_inputs_for_model as _cli_cc ,
_should_accept_session_context_length_refresh as _accept_cc ,
)
from agent . model_metadata import get_model_context_length as _g_cc
_sess_model_cc = str ( getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ) . strip ( )
if _sess_model_cc and _cc_cl > 0 :
_lk_cc = _cli_cc (
_sess_model_cc ,
resolved_provider or ' ' ,
base_url = getattr ( agent , ' base_url ' , ' ' ) or resolved_base_url or ' ' ,
api_key = getattr ( agent , ' api_key ' , ' ' ) or resolved_api_key or ' ' ,
cfg = _cfg if isinstance ( _cfg , dict ) else { } ,
)
2026-05-31 13:17:07 +00:00
try :
2026-06-21 18:36:26 +00:00
_real_cc = _g_cc (
_sess_model_cc ,
_lk_cc . base_url ,
api_key = _lk_cc . api_key ,
config_context_length = _lk_cc . config_context_length ,
provider = _lk_cc . provider or resolved_provider or ' ' ,
custom_providers = _lk_cc . custom_providers ,
) or 0
except TypeError :
_real_cc = _g_cc ( _sess_model_cc , _lk_cc . base_url ) or 0
if _real_cc and _real_cc != _cc_cl and _accept_cc ( _cc_cl , _real_cc ) :
2026-05-31 13:17:07 +00:00
_skip_cc_cl = True
except Exception :
pass
if not _skip_cc_cl :
s . context_length = _cc_cl
2026-04-30 16:38:55 +00:00
s . threshold_tokens = getattr ( _cc_for_save , ' threshold_tokens ' , 0 ) or 0
s . last_prompt_tokens = getattr ( _cc_for_save , ' last_prompt_tokens ' , 0 ) or 0
2026-04-30 17:27:56 +00:00
# Fallback: if the compressor didn't report a context_length
# (fresh agent, interrupted stream, or compressor missing the
# attribute), resolve it from the model's static metadata so
# the indicator can still show a meaningful percentage.
# Sourced from PR #1344 (@jasonjcwu) — extracted to a focused
# follow-up after PR #1344 was closed as superseded by #1341.
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
#
# #1896: pass config_context_length, provider, and
# custom_providers so explicit config overrides win over the
# 256K default fallback. Without these, users on 1M-context
# models who set `model.context_length: 1048576` (or rely on
# a `custom_providers` per-model override) get a 256K
# window in the persisted session and the SSE payload —
# which then trips LCM auto-compress at ~25% of the wrong
# value, cascading into 429 floods.
2026-06-01 00:09:21 +00:00
#
# #3256/#3263: ALSO run this fallback when _skip_cc_cl is true
# (non-default model whose compressor carried the stale global
# cap). Without this, a session that already had a stale 232K
# context_length persisted keeps it forever — skipping the
# compressor write removes the re-clobber but never recomputes
# the real per-model window. Recompute and overwrite in that case.
if ( not getattr ( s , ' context_length ' , 0 ) ) or _skip_cc_cl :
2026-04-30 17:27:56 +00:00
try :
from agent . model_metadata import get_model_context_length
2026-06-06 22:57:09 +00:00
from api . routes import _context_length_lookup_inputs_for_model
_cfg_base_url = getattr ( agent , ' base_url ' , ' ' ) or resolved_base_url or ' '
_ctx_lookup = _context_length_lookup_inputs_for_model (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
resolved_provider ,
base_url = _cfg_base_url ,
cfg = _cfg if isinstance ( _cfg , dict ) else { } ,
)
_cfg_ctx_len = _ctx_lookup . config_context_length
_cfg_custom_providers = _ctx_lookup . custom_providers
2026-06-12 15:53:48 +00:00
_cfg_api_key = _ctx_lookup . api_key or getattr ( agent , ' api_key ' , ' ' ) or resolved_api_key or ' '
2026-06-06 22:57:09 +00:00
_cfg_base_url = _ctx_lookup . base_url or _cfg_base_url
_cfg_provider = _ctx_lookup . provider or resolved_provider or ' '
2026-04-30 17:27:56 +00:00
_resolved_cl = get_model_context_length (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
2026-06-06 22:57:09 +00:00
_cfg_base_url ,
2026-06-12 15:53:48 +00:00
api_key = _cfg_api_key ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
config_context_length = _cfg_ctx_len ,
2026-06-06 22:57:09 +00:00
provider = _cfg_provider ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
custom_providers = _cfg_custom_providers ,
2026-04-30 17:27:56 +00:00
)
if _resolved_cl :
s . context_length = _resolved_cl
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
except TypeError :
# Older hermes-agent builds whose get_model_context_length
# signature pre-dates the config_context_length /
# custom_providers kwargs. Retry with the legacy 2-arg
# form so the indicator still resolves *something*.
try :
from agent . model_metadata import get_model_context_length as _legacy_cl
_resolved_cl = _legacy_cl (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
2026-06-06 22:57:09 +00:00
_cfg_base_url ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
)
if _resolved_cl :
s . context_length = _resolved_cl
except Exception :
pass
2026-04-30 17:27:56 +00:00
except Exception :
# Older hermes-agent builds may not expose this helper.
# Better to leave context_length=0 than crash the save.
pass
2026-06-01 00:09:21 +00:00
# #3256/#3263: when we skipped the stale compressor cap for a
# non-default model and recomputed the real per-model window
# above, rescale the persisted threshold_tokens to that real cap
# so the auto-compress trigger and the reloaded context-ring
# match the live snapshot (which already rescales). Without this,
# a reload shows a smaller compression trigger than streaming did.
# Only rescale when both the original cap and threshold are
# positive; otherwise clear the threshold to 0 (consistent with
# the live-snapshot path) rather than leave a stale value.
if _skip_cc_cl :
_orig_cap = _cc_cl # the stale global cap the compressor reported
_orig_thresh = getattr ( s , ' threshold_tokens ' , 0 ) or 0
_real_cap = getattr ( s , ' context_length ' , 0 ) or 0
if _real_cap > 0 and _orig_cap > 0 and _orig_thresh > 0 :
s . threshold_tokens = int ( _orig_thresh * _real_cap / _orig_cap )
else :
s . threshold_tokens = 0
2026-05-11 07:08:51 +00:00
if not ephemeral and s . messages :
2026-05-11 15:16:43 +00:00
_latest_assistant_idx = next (
( idx for idx in range ( len ( s . messages ) - 1 , - 1 , - 1 )
if isinstance ( s . messages [ idx ] , dict ) and s . messages [ idx ] . get ( ' role ' ) == ' assistant ' ) ,
None ,
)
if _latest_assistant_idx is not None :
_latest_assistant = s . messages [ _latest_assistant_idx ]
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " assistant_started " ,
" created_at " : float ( _latest_assistant . get ( ' timestamp ' ) or time . time ( ) ) ,
" assistant_message_index " : _latest_assistant_idx ,
} ,
)
except Exception :
logger . debug ( " Failed to append assistant_started turn journal event " , exc_info = True )
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
_finalize_cancelled_turn ( s , ephemeral = False )
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-04-23 21:25:43 +00:00
s . save ( )
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
_finalize_cancelled_turn ( s , ephemeral = False )
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-05-11 07:08:51 +00:00
if not ephemeral :
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " completed " ,
" created_at " : time . time ( ) ,
" assistant_message_index " : next (
( idx for idx in range ( len ( s . messages ) - 1 , - 1 , - 1 )
if isinstance ( s . messages [ idx ] , dict ) and s . messages [ idx ] . get ( ' role ' ) == ' assistant ' ) ,
None ,
) ,
} ,
)
except Exception :
logger . debug ( " Failed to append completed turn journal event " , exc_info = True )
2026-05-17 09:30:06 +00:00
if not ephemeral :
2026-05-17 11:16:46 +00:00
# ── Memory-provider lifecycle: mark turn completed (CLI parity) ──
# Completed, non-ephemeral turns are marked dirty/uncommitted so
# boundary drains know there is work. Per CLI semantics, the
# actual memory extraction/commit happens only at session boundaries
# (new session creation, LRU eviction, shutdown drain) — NOT after
# every completed turn. This mirrors Hermes CLI where
# run_agent.py::_sync_external_memory_for_turn() records messages
# but only AIAgent.commit_memory_session()/shutdown_memory_provider()
# trigger extraction via provider on_session_end(). The mark is
# in-memory bookkeeping, not provider I/O, so keep it inside the
# per-session writeback lock to preserve completed-turn ordering.
try :
from api . session_lifecycle import mark_turn_completed
mark_turn_completed ( s . session_id , agent = agent )
except Exception :
logger . debug ( " Memory lifecycle mark failed for session %s " , s . session_id , exc_info = True )
2026-06-04 22:21:41 +00:00
try :
_persistent_changes = _persistent_state_changes (
_persistent_state_before ,
_persistent_state_snapshot ( _profile_home ) ,
)
if _persistent_changes . get ( " memory_saved " ) :
put ( " state_saved " , {
" session_id " : session_id ,
" kind " : " memory " ,
" action " : " saved " ,
} )
for _skill_change in _persistent_changes . get ( " skills " ) or [ ] :
put ( " state_saved " , {
" session_id " : session_id ,
" kind " : " skill " ,
" action " : _skill_change . get ( " action " ) or " updated " ,
" name " : _skill_change . get ( " name " ) or " " ,
} )
except Exception :
logger . debug ( " Persistent state change detection failed for session %s " , s . session_id , exc_info = True )
2026-04-05 03:07:05 +00:00
# Sync to state.db for /insights (opt-in setting)
try :
from api . config import load_settings as _load_settings
if _load_settings ( ) . get ( ' sync_to_insights ' ) :
from api . state_sync import sync_session_usage
sync_session_usage (
session_id = s . session_id ,
input_tokens = s . input_tokens or 0 ,
output_tokens = s . output_tokens or 0 ,
estimated_cost = s . estimated_cost ,
model = model ,
title = s . title ,
2026-04-07 05:56:27 +00:00
message_count = len ( s . messages ) ,
2026-05-24 18:57:40 +00:00
# #2762: pass the session's profile explicitly so the
# background-thread state.db lookup doesn't fall
# through to the process-global active profile and
# write to the wrong DB (TLS profile is set on the
# HTTP thread but not propagated to this worker).
profile = getattr ( s , ' profile ' , None ) ,
2026-04-05 03:07:05 +00:00
)
except Exception :
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger . debug ( " Failed to sync session to insights " )
2026-05-04 03:14:49 +00:00
usage = {
' input_tokens ' : input_tokens ,
' output_tokens ' : output_tokens ,
' estimated_cost ' : estimated_cost ,
2026-05-17 02:49:34 +00:00
' cache_read_tokens ' : cache_read_tokens ,
' cache_write_tokens ' : cache_write_tokens ,
2026-05-19 05:51:52 +00:00
' cache_hit_percent ' : cache_hit_percent ,
' turn_cache_hit_percent ' : turn_cache_hit_percent ,
2026-05-04 03:14:49 +00:00
' duration_seconds ' : round ( _turn_duration_seconds , 3 ) ,
}
2026-05-04 17:55:23 +00:00
if _turn_tps is not None :
usage [ ' tps ' ] = _turn_tps
2026-05-05 00:08:23 +00:00
if _gateway_routing :
usage [ ' gateway_routing ' ] = _gateway_routing
2026-04-30 16:38:55 +00:00
# Include context window data from the agent's compressor for the UI indicator.
# The session-level persistence happens above (before s.save()) so the values
# survive a page reload; this block only populates the live SSE usage payload.
2026-04-05 01:50:17 +00:00
_cc = getattr ( agent , ' context_compressor ' , None )
if _cc :
2026-05-31 13:17:07 +00:00
_cc_cl_sse = getattr ( _cc , ' context_length ' , 0 ) or 0
2026-06-01 00:09:21 +00:00
# #3256/#3263: remember the original compressor cap + threshold
# so that if we drop the stale cap below and the fallback
# resolves the real per-model window, we can rescale the
# threshold consistently (the live snapshot already does this).
_orig_cc_cl_sse = _cc_cl_sse
_orig_cc_thresh_sse = getattr ( _cc , ' threshold_tokens ' , 0 ) or 0
_dropped_stale_cap_sse = False
2026-06-21 18:36:26 +00:00
# Default-only guard (#3256), broadened (#4618): the agent-side
# context_compressor caches a context_length from the model it
# was built/last-updated with. For a non-default model it may be
# the stale global cap (e.g. 232K); after an in-place model switch
# it may be a DIFFERENT model's window (e.g. opus-4.5's 168k on an
# opus-4.8 1M session). Either way, surfacing it via the terminal
# `done` SSE makes the indicator REVERT to the wrong window on
# stream end (messages.js overwrites S.lastUsage with this payload)
# — the exact "send a message reverts to 168k" symptom. Resolve
# the real per-model window via the SAME helper the live path +
# hydration use; drop the compressor value whenever the real
# window differs, honoring the #4248 acceptance gate (never let a
# low-confidence 256k fallback clobber a larger cached window).
2026-05-31 13:17:07 +00:00
try :
2026-06-21 18:36:26 +00:00
from api . routes import (
_context_length_lookup_inputs_for_model as _cli_sse ,
_should_accept_session_context_length_refresh as _accept_sse ,
)
from agent . model_metadata import get_model_context_length as _g_sse
_sess_model_sse = str ( getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ) . strip ( )
if _sess_model_sse and _cc_cl_sse > 0 :
_lk_sse = _cli_sse (
_sess_model_sse ,
resolved_provider or ' ' ,
base_url = getattr ( agent , ' base_url ' , ' ' ) or resolved_base_url or ' ' ,
api_key = getattr ( agent , ' api_key ' , ' ' ) or resolved_api_key or ' ' ,
cfg = _cfg if isinstance ( _cfg , dict ) else { } ,
)
2026-05-31 13:17:07 +00:00
try :
2026-06-21 18:36:26 +00:00
_real_sse = _g_sse (
_sess_model_sse ,
_lk_sse . base_url ,
api_key = _lk_sse . api_key ,
config_context_length = _lk_sse . config_context_length ,
provider = _lk_sse . provider or resolved_provider or ' ' ,
custom_providers = _lk_sse . custom_providers ,
) or 0
except TypeError :
_real_sse = _g_sse ( _sess_model_sse , _lk_sse . base_url ) or 0
if _real_sse and _real_sse != _cc_cl_sse and _accept_sse ( _cc_cl_sse , _real_sse ) :
2026-05-31 13:17:07 +00:00
_cc_cl_sse = 0
2026-06-01 00:09:21 +00:00
_dropped_stale_cap_sse = True
2026-05-31 13:17:07 +00:00
except Exception :
pass
if _cc_cl_sse :
usage [ ' context_length ' ] = _cc_cl_sse
2026-04-05 01:50:17 +00:00
usage [ ' threshold_tokens ' ] = getattr ( _cc , ' threshold_tokens ' , 0 ) or 0
usage [ ' last_prompt_tokens ' ] = getattr ( _cc , ' last_prompt_tokens ' , 0 ) or 0
2026-04-30 21:32:45 +00:00
# Fallback: when the compressor is absent or reports context_length=0,
# resolve the model's context window from metadata so the UI indicator
# shows the correct percentage rather than overflowing against the 128K
# JS default. Mirrors the session-save fallback above (lines ~2205-2217).
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
#
# #1896: pass config_context_length, provider, and custom_providers so
# explicit config overrides win over the 256K default fallback. The
# SSE payload's `context_length` is what feeds the live token-usage
# indicator, so a stale 256K here surfaces as the same wrong-window
# display that motivates this fix.
2026-04-30 21:32:45 +00:00
if not usage . get ( ' context_length ' ) :
try :
from agent . model_metadata import get_model_context_length as _get_cl
2026-06-06 22:57:09 +00:00
from api . routes import _context_length_lookup_inputs_for_model
_ctx_lookup = _context_length_lookup_inputs_for_model (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
resolved_provider ,
base_url = getattr ( agent , ' base_url ' , ' ' ) or resolved_base_url or ' ' ,
cfg = _cfg if isinstance ( _cfg , dict ) else { } ,
)
_cfg_ctx_len = _ctx_lookup . config_context_length
_cfg_custom_providers = _ctx_lookup . custom_providers
2026-06-12 15:53:48 +00:00
_cfg_api_key = _ctx_lookup . api_key or getattr ( agent , ' api_key ' , ' ' ) or resolved_api_key or ' '
2026-06-06 22:57:09 +00:00
_cfg_base_url = _ctx_lookup . base_url
_cfg_provider = _ctx_lookup . provider or resolved_provider or ' '
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
try :
_fb_cl = _get_cl (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
2026-06-06 22:57:09 +00:00
_cfg_base_url ,
2026-06-12 15:53:48 +00:00
api_key = _cfg_api_key ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
config_context_length = _cfg_ctx_len ,
2026-06-06 22:57:09 +00:00
provider = _cfg_provider ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
custom_providers = _cfg_custom_providers ,
)
except TypeError :
# Older hermes-agent builds: fall back to legacy 2-arg form.
_fb_cl = _get_cl (
getattr ( agent , ' model ' , resolved_model or ' ' ) or ' ' ,
2026-06-06 22:57:09 +00:00
_cfg_base_url ,
fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.
For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.
Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.
Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.
Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).
Closes #1896
Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 15:17:59 +00:00
)
2026-04-30 21:32:45 +00:00
if _fb_cl :
usage [ ' context_length ' ] = _fb_cl
2026-06-01 00:09:21 +00:00
# #3256/#3263: if we dropped the stale compressor cap
# for a non-default model, the threshold_tokens written
# above is still the stale compressor value. Rescale it
# to the real resolved window so the terminal `done`
# payload matches the live snapshot (which rescales) —
# otherwise messages.js overwrites S.lastUsage with the
# stale threshold and the indicator reverts on stream end.
if _dropped_stale_cap_sse and _orig_cc_cl_sse > 0 and _orig_cc_thresh_sse > 0 :
usage [ ' threshold_tokens ' ] = int ( _orig_cc_thresh_sse * _fb_cl / _orig_cc_cl_sse )
2026-04-30 21:32:45 +00:00
except Exception :
pass
# Fallback: when last_prompt_tokens is missing (no compressor), use the
# session-persisted value rather than letting the frontend fall back to
# the cumulative input_tokens counter, which overflows for long sessions.
if not usage . get ( ' last_prompt_tokens ' ) :
_sess_lpt = getattr ( s , ' last_prompt_tokens ' , 0 ) or 0
if _sess_lpt :
usage [ ' last_prompt_tokens ' ] = _sess_lpt
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
# (reasoning trace already attached + saved above, before s.save())
2026-04-26 02:21:00 +00:00
# Leftover-steer delivery: if a /steer was queued (via
# api/chat/steer) but the agent finished its turn before
# reaching a tool-result boundary that would consume it,
# the text is still stashed in agent._pending_steer. Drain
# it now and emit a pending_steer_leftover SSE event so the
# frontend can queue it for the next turn — same fallback
# path as the CLI in cli.py:8788-8794.
try :
_drain_pending_steer = getattr ( agent , ' _drain_pending_steer ' , None )
_leftover = _drain_pending_steer ( ) if _drain_pending_steer else None
if _leftover :
put ( ' pending_steer_leftover ' , {
' session_id ' : session_id ,
' text ' : str ( _leftover ) ,
} )
except Exception :
logger . debug ( " Failed to drain pending steer for session %s " , session_id )
2026-05-08 02:32:06 +00:00
# /goal parity: after a successful assistant turn, run the Hermes
# GoalManager judge before terminal done/stream_end events. The
# frontend surfaces the status line and queues continuation_prompt as
# a normal next user message so /queue and user input keep priority.
2026-05-09 07:08:13 +00:00
# #1932: only evaluate when the turn was goal-related (set via
# STREAM_GOAL_RELATED or goal_related parameter).
2026-05-08 02:32:06 +00:00
try :
2026-05-08 15:20:24 +00:00
from api . goals import evaluate_goal_after_turn , has_active_goal
2026-05-09 07:08:13 +00:00
if not goal_related or not has_active_goal ( session_id , profile_home = _profile_home ) :
2026-05-08 15:20:24 +00:00
_goal_decision = { }
else :
_last_goal_response = ' '
for _goal_msg in reversed ( s . messages or [ ] ) :
if not isinstance ( _goal_msg , dict ) or _goal_msg . get ( ' role ' ) != ' assistant ' :
continue
_goal_content = _goal_msg . get ( ' content ' , ' ' )
if isinstance ( _goal_content , list ) :
_goal_parts = [ ]
for _goal_part in _goal_content :
if isinstance ( _goal_part , dict ) :
_goal_text = _goal_part . get ( ' text ' ) or _goal_part . get ( ' content ' )
if _goal_text :
_goal_parts . append ( str ( _goal_text ) )
_last_goal_response = ' \n ' . join ( _goal_parts )
else :
_last_goal_response = str ( _goal_content or ' ' )
break
put ( ' goal ' , {
' session_id ' : session_id ,
' state ' : ' evaluating ' ,
' message ' : ' Evaluating goal progress… ' ,
2026-05-10 07:21:24 +00:00
' message_key ' : ' goal_evaluating_progress ' ,
2026-05-08 15:20:24 +00:00
} )
_goal_decision = evaluate_goal_after_turn (
session_id ,
_last_goal_response ,
user_initiated = True ,
profile_home = _profile_home ,
)
2026-05-08 02:32:06 +00:00
decision = _goal_decision or { }
_goal_message = str ( decision . get ( ' message ' ) or ' ' ) . strip ( )
if _goal_message :
put ( ' goal ' , {
' session_id ' : session_id ,
2026-05-08 15:20:24 +00:00
' state ' : ' continuing ' if decision . get ( ' should_continue ' ) else ' idle ' ,
2026-05-08 02:32:06 +00:00
' message ' : _goal_message ,
2026-05-10 07:21:24 +00:00
' message_key ' : decision . get ( ' message_key ' ) or ( ' goal_continuing ' if _goal_message else ' ' ) ,
' message_args ' : decision . get ( ' message_args ' ) or [ ] ,
2026-05-08 02:32:06 +00:00
' decision ' : decision ,
} )
if decision . get ( ' should_continue ' ) :
continuation_prompt = str ( decision . get ( ' continuation_prompt ' ) or ' ' ) . strip ( )
if continuation_prompt :
2026-05-09 07:08:13 +00:00
# #1932: mark this session as pending a goal continuation
# so the next /chat/start creates a goal-related stream.
PENDING_GOAL_CONTINUATION . add ( session_id )
2026-05-08 02:32:06 +00:00
put ( ' goal_continue ' , {
' session_id ' : session_id ,
' continuation_prompt ' : continuation_prompt ,
' text ' : continuation_prompt ,
' message ' : _goal_message ,
2026-05-10 07:21:24 +00:00
' message_key ' : decision . get ( ' message_key ' ) or ' goal_continuing ' ,
' message_args ' : decision . get ( ' message_args ' ) or [ ] ,
2026-05-08 02:32:06 +00:00
' decision ' : decision ,
} )
except Exception as _goal_exc :
logger . debug ( " Goal continuation hook failed for session %s : %s " , session_id , _goal_exc )
2026-06-14 18:54:47 +00:00
raw_session = _session_payload_with_full_messages ( s , tool_calls = tool_calls )
2026-06-08 08:24:34 +00:00
_done_payload = { ' session ' : redact_session_data ( raw_session ) , ' usage ' : usage }
if _tool_limit_reached :
_done_payload [ ' terminal_state ' ] = ' tool_limit_reached '
_done_payload [ ' terminal_reason ' ] = ' max_iterations '
put ( ' done ' , _done_payload )
2026-05-04 17:55:23 +00:00
# Emit one last metering packet for the live message-header TPS label.
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
meter_stats = meter ( ) . get_stats ( )
meter_stats [ ' session_id ' ] = session_id
2026-05-04 17:55:23 +00:00
meter_stats . setdefault ( ' tps_available ' , False )
meter_stats . setdefault ( ' estimated ' , False )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
put ( ' metering ' , meter_stats )
2026-04-15 14:50:59 +00:00
if _should_bg_title and _u0 and _a0 :
threading . Thread (
target = _run_background_title_update ,
args = ( s . session_id , _u0 , _a0 , str ( s . title or ' ' ) . strip ( ) , put , agent ) ,
daemon = True ,
) . start ( )
else :
2026-04-20 22:48:19 +00:00
# Use the original session_id parameter (never reassigned), not s.session_id
# which may be rotated during context compression. The client captured
# activeSid = original session_id so they must match for stream_end to close.
put ( ' stream_end ' , { ' session_id ' : session_id } )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
# Adaptive title refresh: re-generate title from latest exchange
# every N exchanges (when enabled in settings). Runs after stream_end
# so it doesn't block the stream.
_maybe_schedule_title_refresh ( s , put , agent )
2026-04-08 14:22:39 +00:00
finally :
2026-06-23 02:44:34 +00:00
# #4729: guaranteed-exit flush of any reasoning tail still buffered. On the
# normal path the on_token/on_tool/post-run flushes already emptied it (no-op
# here); on an exception or retry path that bypassed those, this emits the tail
# before the outer handler sends apperror — so the live Thinking view never
# loses its last coalesced chunk. Runs before stream teardown; STREAM_REASONING_TEXT
# already mirrors the full text for persistence regardless.
try :
_flush_reasoning_buffer ( )
except Exception :
pass
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
# Stop the live metering ticker
_metering_stop . set ( )
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
# Unregister the gateway approval callback and unblock any threads
# still waiting on approval (e.g. stream cancelled mid-approval).
if _approval_registered and _unreg_notify is not None :
try :
_unreg_notify ( session_id )
except Exception :
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger . debug ( " Failed to unregister approval callback " )
2026-06-13 17:49:32 +00:00
if _cleanup_gateway_pending_mirror is not None :
try :
_cleanup_gateway_pending_mirror ( )
except Exception :
logger . debug ( " Failed to reconcile gateway approval mirror " )
2026-04-15 05:10:50 +00:00
if _clarify_registered and _unreg_clarify_notify is not None :
try :
_unreg_clarify_notify ( session_id )
except Exception :
logger . debug ( " Failed to unregister clarify callback " )
release: v0.39.0 — security hardening, 12 fixes (#171)
* Security: harden auth, CSRF, SSRF, XSS, and env race conditions
Twelve fixes from a full security audit:
CRITICAL
- Add CSRF Origin/Referer validation on all POST endpoints
(prevents cross-origin abuse of self-update, settings, file ops)
HIGH
- Unify password hashing: config.py now uses PBKDF2 (600k iters)
instead of single-iteration SHA-256
- Add per-IP rate limiting on login (5 attempts/60s, 429 on excess)
MEDIUM
- Validate session IDs as hex-only before filesystem operations
(prevents path traversal via crafted session ID)
- SSRF: resolve DNS before private-IP check in model fetching
(prevents DNS rebinding to internal services)
- Warn loudly when binding non-loopback without password set
- SSE env var mutations: wrap sync chat + streaming restore in _ENV_LOCK
- Force Content-Disposition:attachment for HTML/XHTML/SVG uploads
(prevents stored XSS via uploaded files)
LOW
- Extend HMAC session signature from 64 to 128 bits
- Add resolve()+relative_to() check on skills path construction
- Set Secure flag on session cookie when connection is HTTPS
- Sanitize exception messages to strip filesystem paths
No breaking changes. All fixes are backward-compatible.
* fix: use getattr for Secure cookie SSL detection
handler.request.getpeercert raises AttributeError on plain sockets
(non-SSL). Use getattr(..., None) to safely check for SSL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* tests: add sprint 29 security hardening coverage (PR #171)
33 tests covering all 12 security fixes:
- CSRF origin/referer validation
- Login rate limiting (5 attempts/60s)
- Session ID hex validation (path traversal prevention)
- Error path sanitization (_sanitize_error)
- Secure cookie getattr safety
- HMAC signature length (64->128 bit)
- Skills path traversal prevention
- Content-Disposition for HTML/SVG/XHTML
- PBKDF2 password hashing verification
- Non-loopback startup warning
- SSRF DNS guard code presence
- _ENV_LOCK export from streaming module
* release: v0.39.0 — security hardening, 12 fixes (#171)
---------
Co-authored-by: betamod <matthew.sloly@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 05:26:03 +00:00
with _ENV_LOCK :
2026-04-29 06:12:59 +00:00
for _key , _old_value in old_profile_env . items ( ) :
if _old_value is None : os . environ . pop ( _key , None )
else : os . environ [ _key ] = _old_value
2026-04-08 14:22:39 +00:00
if old_cwd is None : os . environ . pop ( ' TERMINAL_CWD ' , None )
else : os . environ [ ' TERMINAL_CWD ' ] = old_cwd
if old_exec_ask is None : os . environ . pop ( ' HERMES_EXEC_ASK ' , None )
else : os . environ [ ' HERMES_EXEC_ASK ' ] = old_exec_ask
if old_session_key is None : os . environ . pop ( ' HERMES_SESSION_KEY ' , None )
else : os . environ [ ' HERMES_SESSION_KEY ' ] = old_session_key
2026-05-15 00:22:44 +00:00
if old_session_id is None : os . environ . pop ( ' HERMES_SESSION_ID ' , None )
else : os . environ [ ' HERMES_SESSION_ID ' ] = old_session_id
if old_session_platform is None : os . environ . pop ( ' HERMES_SESSION_PLATFORM ' , None )
else : os . environ [ ' HERMES_SESSION_PLATFORM ' ] = old_session_platform
2026-06-09 05:36:18 +00:00
if old_session_chat_id is None : os . environ . pop ( ' HERMES_SESSION_CHAT_ID ' , None )
else : os . environ [ ' HERMES_SESSION_CHAT_ID ' ] = old_session_chat_id
2026-04-08 14:22:39 +00:00
if old_hermes_home is None : os . environ . pop ( ' HERMES_HOME ' , None )
else : os . environ [ ' HERMES_HOME ' ] = old_hermes_home
2026-03-31 03:40:19 +00:00
except Exception as e :
2026-04-03 13:41:32 +00:00
print ( ' [webui] stream error: \n ' + traceback . format_exc ( ) , flush = True )
2026-04-20 22:48:19 +00:00
err_str = str ( e )
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
# Sanitize HTML from provider error responses — some providers return
# full HTML pages (e.g. nginx "404 page not found") instead of JSON errors.
# Strip HTML tags to avoid rendering raw markup in the chat message.
_stripped = re . sub ( r ' <[^>]+> ' , ' ' , err_str )
_stripped = re . sub ( r ' \ s+ ' , ' ' , _stripped ) . strip ( )
if _stripped != err_str :
err_str = _stripped
2026-04-20 22:48:19 +00:00
_exc_lower = err_str . lower ( )
2026-05-07 01:15:53 +00:00
_classification = _classify_provider_error ( err_str , e )
2026-05-12 19:53:28 +00:00
if cancel_event . is_set ( ) :
if s is not None :
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib . nullcontext ( )
with _lock_ctx :
_finalize_cancelled_turn ( s , ephemeral = ephemeral )
if not ephemeral :
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : " cancelled " ,
} ,
)
except Exception :
logger . debug ( " Failed to append cancelled turn journal event " , exc_info = True )
put ( ' cancel ' , { ' message ' : ' Cancelled by user ' } )
return
2026-05-07 01:15:53 +00:00
_exc_is_quota = _classification [ ' type ' ] == ' quota_exhausted '
# Exception quota text still includes: 'more credits' in _exc_lower, 'can only afford' in _exc_lower, 'fewer max_tokens' in _exc_lower.
# Rate-limit detection remains guarded as: (not _exc_is_quota).
_exc_is_rate_limit = ( _classification [ ' type ' ] == ' rate_limit ' ) and ( not _exc_is_quota )
_exc_is_auth = _classification [ ' type ' ] == ' auth_mismatch ' # detects '401' and 'unauthorized' via _classify_provider_error.
_exc_is_not_found = _classification [ ' type ' ] == ' model_not_found ' # detects '404', 'not found', 'does not exist', and 'invalid model'.
2026-05-12 19:53:28 +00:00
_exc_is_cancelled = _classification [ ' type ' ] == ' cancelled '
_exc_is_interrupted = _classification [ ' type ' ] == ' interrupted '
2026-05-07 01:15:53 +00:00
# The user hint still points to Settings / `hermes model` from _classify_provider_error().
2026-04-20 22:48:19 +00:00
if _exc_is_quota :
_exc_label , _exc_type , _exc_hint = (
2026-05-07 01:15:53 +00:00
_classification [ ' label ' ] , _classification [ ' type ' ] , _classification [ ' hint ' ] ,
2026-04-20 22:48:19 +00:00
)
elif _exc_is_rate_limit :
_exc_label , _exc_type , _exc_hint = (
2026-05-07 01:15:53 +00:00
_classification [ ' label ' ] , _classification [ ' type ' ] , _classification [ ' hint ' ] ,
2026-04-20 22:48:19 +00:00
)
elif _exc_is_auth :
2026-05-03 18:32:53 +00:00
if not _self_healed :
# ── Credential self-heal on 401 (#1401) ──
_heal_rt = _attempt_credential_self_heal (
resolved_provider or ' ' , session_id , _agent_lock ,
2026-06-12 01:08:24 +00:00
target_model = resolved_model ,
2026-05-03 18:32:53 +00:00
)
if _heal_rt is not None :
logger . info ( ' [webui] self-heal (except path): retrying stream after credential refresh ' )
_self_healed = True
# Rebuild runtime variables
_rt = _heal_rt
resolved_api_key = _heal_rt . get ( ' api_key ' )
if not resolved_provider :
resolved_provider = _heal_rt . get ( ' provider ' )
2026-06-12 00:56:22 +00:00
resolved_base_url = _runtime_preferred_base_url (
2026-06-12 01:06:16 +00:00
_heal_rt , resolved_provider , configured_base_url
2026-06-12 00:56:22 +00:00
)
2026-05-18 11:27:31 +00:00
resolved_provider , resolved_api_key , resolved_base_url = _resolve_custom_provider_runtime_overrides (
resolved_provider , resolved_api_key , resolved_base_url
)
2026-05-03 18:32:53 +00:00
# Build a fresh agent with the new credentials
_heal_kwargs = dict ( _agent_kwargs ) if ' _agent_kwargs ' in dir ( ) else { }
_heal_kwargs [ ' api_key ' ] = resolved_api_key
_heal_kwargs [ ' base_url ' ] = resolved_base_url
_heal_kwargs [ ' model ' ] = resolved_model
_heal_kwargs [ ' provider ' ] = resolved_provider
2026-06-04 20:04:01 +00:00
_replace_session_db_in_kwargs ( _heal_kwargs , _state_db_path )
2026-05-03 18:32:53 +00:00
if ' credential_pool ' in _agent_params :
_heal_kwargs [ ' credential_pool ' ] = _heal_rt . get ( ' credential_pool ' )
_heal_agent = _AIAgent ( * * _heal_kwargs )
with STREAMS_LOCK :
AGENT_INSTANCES [ stream_id ] = _heal_agent
from api . config import SESSION_AGENT_CACHE as _SAC2 , SESSION_AGENT_CACHE_LOCK as _SAC2_L
with _SAC2_L :
_SAC2 [ session_id ] = ( _heal_agent , _agent_sig )
_SAC2 . move_to_end ( session_id )
# Retry the conversation
_token_sent = False
try :
_heal_result = _heal_agent . run_conversation (
user_message = user_message ,
system_message = workspace_system_msg ,
2026-05-16 10:54:28 +00:00
conversation_history = _sanitize_messages_for_api ( _previous_context_messages , cfg = _cfg ) ,
2026-05-03 18:32:53 +00:00
task_id = session_id ,
persist_user_message = msg_text ,
)
# Retry succeeded — persist the result normally
if s is not None :
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib . nullcontext ( )
with _lock_ctx :
2026-05-13 02:23:03 +00:00
if not ephemeral and not _stream_writeback_is_current ( s , stream_id ) :
logger . info (
" Skipping stale stream self-heal writeback for session %s stream %s ; active_stream_id= %s " ,
getattr ( s , ' session_id ' , session_id ) ,
stream_id ,
getattr ( s , ' active_stream_id ' , None ) ,
)
return
2026-05-03 18:32:53 +00:00
_result_messages = _heal_result . get ( ' messages ' ) or _previous_context_messages
_next_context_messages = _restore_reasoning_metadata (
_previous_context_messages , _result_messages ,
)
2026-05-21 05:42:19 +00:00
_next_context_messages = _dedupe_replayed_context_messages (
2026-05-20 21:15:54 +00:00
_previous_context_messages ,
_next_context_messages ,
2026-06-16 17:03:37 +00:00
msg_text ,
2026-05-20 21:15:54 +00:00
)
2026-05-21 17:56:43 +00:00
s . context_messages = _deduplicate_context_messages ( _next_context_messages )
2026-05-03 18:32:53 +00:00
s . messages = _merge_display_messages_after_agent_result (
_previous_messages ,
_previous_context_messages ,
_restore_reasoning_metadata ( _previous_messages , _result_messages ) ,
msg_text ,
2026-06-18 22:21:10 +00:00
source = getattr ( s , ' pending_user_source ' , None ) or ' webui ' ,
2026-05-03 18:32:53 +00:00
)
2026-06-23 09:55:29 +00:00
_advance_truncation_watermark_after_commit ( s ) # #3831
2026-05-03 18:32:53 +00:00
s . save ( )
logger . info ( ' [webui] self-heal (except path): retry succeeded ' )
return # skip error emission
except Exception as _retry_exc2 :
logger . warning ( ' [webui] self-heal (except path): retry failed: %s ' , _retry_exc2 )
# Fall through to emit the original error
# Self-heal didn't apply or retry failed — emit the auth error
2026-04-20 22:48:19 +00:00
_exc_label , _exc_type , _exc_hint = (
' Authentication error ' , ' auth_mismatch ' ,
' The selected model may not be supported by your configured provider. '
' Run `hermes model` in your terminal to switch providers, then restart the WebUI. ' ,
)
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
elif _exc_is_not_found :
_exc_label , _exc_type , _exc_hint = (
2026-05-07 01:15:53 +00:00
_classification [ ' label ' ] , _classification [ ' type ' ] , _classification [ ' hint ' ] ,
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
)
2026-05-12 19:53:28 +00:00
elif _exc_is_cancelled or _exc_is_interrupted :
_exc_label , _exc_type , _exc_hint = (
_classification [ ' label ' ] , _classification [ ' type ' ] , _classification [ ' hint ' ] ,
)
2026-04-20 22:48:19 +00:00
else :
_exc_label , _exc_type , _exc_hint = ' Error ' , ' error ' , ' '
2026-05-07 01:15:53 +00:00
_error_payload = _provider_error_payload ( err_str , _exc_type , _exc_hint )
2026-04-13 23:18:15 +00:00
if s is not None :
2026-04-23 21:25:43 +00:00
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
2026-04-20 22:48:19 +00:00
# Persist the error so it survives page reload.
# _error=True ensures _sanitize_messages_for_api excludes it from subsequent
# API calls so the LLM never sees its own error as prior context on the next turn.
2026-04-23 21:25:43 +00:00
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib . nullcontext ( )
with _lock_ctx :
2026-05-13 02:23:03 +00:00
if not ephemeral and not _stream_writeback_is_current ( s , stream_id ) :
logger . info (
" Skipping stale stream error writeback for session %s stream %s ; active_stream_id= %s " ,
getattr ( s , ' session_id ' , session_id ) ,
stream_id ,
getattr ( s , ' active_stream_id ' , None ) ,
)
return
2026-06-10 19:05:48 +00:00
2026-05-06 20:47:58 +00:00
_materialize_pending_user_turn_before_error ( s )
2026-04-23 21:25:43 +00:00
s . active_stream_id = None
s . pending_user_message = None
s . pending_attachments = [ ]
s . pending_started_at = None
2026-06-18 22:21:10 +00:00
s . pending_user_source = None
2026-06-10 19:05:48 +00:00
try :
_snapshot_and_append_partial_on_error ( s , stream_id )
except Exception :
logger . debug ( " Failed to snapshot partials on error for %s " , stream_id , exc_info = True )
2026-05-07 01:15:53 +00:00
_error_message = {
2026-04-23 21:25:43 +00:00
' role ' : ' assistant ' ,
2026-05-07 01:15:53 +00:00
' content ' : f ' ** { _exc_label } :** { _error_payload . get ( " message " ) or err_str } ' + ( f ' \n \n * { _exc_hint } * ' if _exc_hint else ' ' ) ,
2026-04-23 21:25:43 +00:00
' timestamp ' : int ( time . time ( ) ) ,
' _error ' : True ,
2026-05-07 01:15:53 +00:00
}
if _error_payload . get ( ' details ' ) :
_error_message [ ' provider_details ' ] = _error_payload [ ' details ' ]
2026-05-12 19:53:28 +00:00
if _exc_type == ' cancelled ' :
_error_message [ ' provider_details_label ' ] = ' Cancellation details '
elif _exc_type == ' interrupted ' :
_error_message [ ' provider_details_label ' ] = ' Interruption details '
2026-05-07 01:15:53 +00:00
s . messages . append ( _error_message )
2026-04-23 21:25:43 +00:00
try :
s . save ( )
except Exception :
pass
2026-05-11 07:08:51 +00:00
if not ephemeral :
try :
append_turn_journal_event_for_stream (
s . session_id ,
stream_id ,
{
" event " : " interrupted " ,
" created_at " : time . time ( ) ,
" reason " : _exc_type ,
} ,
)
except Exception :
logger . debug ( " Failed to append interrupted turn journal event " , exc_info = True )
2026-06-06 05:45:09 +00:00
_error_payload [ ' session_id ' ] = getattr ( s , ' session_id ' , session_id )
_error_payload [ ' old_session_id ' ] = session_id
2026-05-07 01:15:53 +00:00
put ( ' apperror ' , _error_payload )
2026-03-31 03:40:19 +00:00
finally :
2026-04-28 10:09:26 +00:00
# Stop the periodic checkpoint thread before the final recovery path.
# The checkpoint thread also uses the per-session lock; joining it first
# avoids contending with checkpoint writes during stale-pending repair.
2026-04-21 19:07:44 +00:00
if _checkpoint_stop is not None :
_checkpoint_stop . set ( )
2026-04-23 21:25:43 +00:00
if _ckpt_thread is not None :
_ckpt_thread . join ( timeout = 15 )
2026-04-28 10:09:26 +00:00
if ( s is not None
and getattr ( s , ' active_stream_id ' , None ) == stream_id
and getattr ( s , ' pending_user_message ' , None ) ) :
2026-05-11 00:15:00 +00:00
update_active_run ( stream_id , phase = " finalizing " )
2026-04-28 10:09:26 +00:00
_last_resort_sync_from_core ( s , stream_id , _agent_lock )
2026-03-31 03:40:19 +00:00
_clear_thread_env ( ) # TD1: always clear thread-local context
2026-06-22 03:38:28 +00:00
if _streaming_cron_profile_home_token is not None :
_STREAMING_CRON_PROFILE_HOME . reset ( _streaming_cron_profile_home_token )
2026-06-09 05:36:18 +00:00
# xsession wakeup misroute root fix (Option 1): restore the per-turn
# session-identity context-locals (reset-token semantics). MUST run on
# every exit path so a reused thread-pool worker leaks no identity and
# CLI/cron env fallback resumes — same lifecycle slot as the env
# restore above.
_reset_turn_session_identity ( _turn_session_identity_tokens )
2026-03-31 03:40:19 +00:00
with STREAMS_LOCK :
STREAMS . pop ( stream_id , None )
CANCEL_FLAGS . pop ( stream_id , None )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
AGENT_INSTANCES . pop ( stream_id , None ) # Clean up agent instance reference
2026-04-23 18:16:59 +00:00
STREAM_PARTIAL_TEXT . pop ( stream_id , None ) # Clean up partial text buffer (#893)
2026-04-30 23:24:29 +00:00
STREAM_REASONING_TEXT . pop ( stream_id , None ) # Clean up reasoning trace (#1361 §A)
STREAM_LIVE_TOOL_CALLS . pop ( stream_id , None ) # Clean up tool calls (#1361 §B)
2026-05-09 07:08:13 +00:00
STREAM_GOAL_RELATED . pop ( stream_id , None ) # Clean up goal-related flag (#1932)
stage-364: Opus-caught live SSE event_id fix (side-channel approach)
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.
Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
→ double-rendered output.
Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.
Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
tests: test_cancel_interrupt, test_sprint42, test_sprint51,
test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
other tests: test_smooth_text_fade, test_streaming_markdown,
test_streaming_race_fix. _wireSSE must NOT reset accumulators because
legacy reconnect doesn't replay events; only journal-replay does.
Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.
6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.
Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
2026-05-16 03:58:31 +00:00
STREAM_LAST_EVENT_ID . pop ( stream_id , None ) # Clean up event_id pointer (stage-364)
2026-05-11 00:15:00 +00:00
unregister_active_run ( stream_id )
2026-05-09 18:36:01 +00:00
# NOTE: do NOT discard PENDING_GOAL_CONTINUATION here. The marker
# is set by goal_continue (line ~3328) inside the SAME function
# call and consumed atomically by `_start_chat_stream_for_session`
# in routes.py (around line 6522) when the next stream starts.
# Discarding here in the streaming worker's `finally` would
# almost always race ahead of the frontend's SSE-receive →
# POST /api/chat/start round-trip and erase the marker before
# the next stream can read it, breaking the goal-continuation
# chain. Stage-326 critical fix per Opus advisor review.
2026-03-31 03:40:19 +00:00
2026-06-09 05:36:18 +00:00
# ── Defer-path fix: turn-teardown idle-hook ────────────────────────
# The session has just transitioned active→idle: unregister_active_run
# above cleared this stream's ACTIVE_RUNS row (under ACTIVE_RUNS_LOCK,
# independent of STREAMS_LOCK), so _session_has_active_turn() is now
# False for this session unless a *different* stream is still active
# (cancel/reconnect — drain_deferred_wakeups_for_session guards on
# that and leaves the marker for the later teardown). A FAST
# background task that completed while this turn was tearing down was
# deferred by api/background_process._process_one (it could not start
# a turn → would 409) and its wakeup_prompt persisted in
# DEFERRED_PROCESS_WAKEUPS. For an autonomous agent there is no next
# user turn, so the PR #2279 next-turn drain never runs; without this
# hook the deferred wakeup is lost forever (the Test B failure). This
# makes the busy-at-completion case symmetric with the idle case:
# idle now → fire now (Option Z idle branch); busy now → fire here at
# turn-end. claim_deferred_wakeups pops atomically, so this is
# idempotent with the next-turn drain (no double-fire) and the wakeup
# turn's own teardown finds nothing claimed (no wakeup loop). The
# drain spawns its own daemon thread, so teardown never blocks.
try :
from api . background_process import drain_deferred_wakeups_for_session
drain_deferred_wakeups_for_session ( session_id )
except Exception :
logger . debug (
" turn-teardown deferred-wakeup drain failed for session %s " ,
session_id ,
exc_info = True ,
)
2026-03-31 03:40:19 +00:00
# ============================================================
# SECTION: HTTP Request Handler
# do_GET: read-only API endpoints + SSE stream + static HTML
# do_POST: mutating endpoints (session CRUD, chat, upload, approval)
# Routing is a flat if/elif chain. See ARCHITECTURE.md section 4.1.
# ============================================================
2026-04-26 02:21:00 +00:00
def _handle_chat_steer ( handler , body : dict ) - > bool :
""" Inject a /steer payload into the active agent for a session.
Mirrors the CLI ' s `/steer <text>` command (cli.py:6140-6155):
- Look up the cached AIAgent for the session ( PR #1051's
SESSION_AGENT_CACHE ) .
- Verify a stream is currently active for this session .
- Call agent . steer ( text ) — thread - safe , stashes text in
_pending_steer for application at the next tool - result boundary .
The agent ' s loop calls _apply_pending_steer_to_tool_results() at the
end of every tool batch and appends the steer text to the last tool
result ' s content with a marker, so the model sees the steer as part
of the tool output on its next iteration . The user ' s stream is NOT
interrupted .
If no agent is cached , the agent is too old to support steer , or no
2026-06-22 06:22:51 +00:00
stream is active , return { " accepted " : False , " fallback " : " <reason> " } .
The frontend must surface that failure without cancelling the active run ;
Steer is active - run guidance , not implicit permission to Queue , Interrupt ,
or Stop - and - send .
2026-04-26 02:21:00 +00:00
Returns 200 with { " accepted " : bool , " fallback " : str | None ,
" stream_id " : str | None } .
"""
from api . helpers import j , bad
2026-04-28 04:39:30 +00:00
from api import config as _cfg
2026-04-26 02:21:00 +00:00
sid = str ( ( body or { } ) . get ( " session_id " , " " ) or " " ) . strip ( )
text = str ( ( body or { } ) . get ( " text " , " " ) or " " ) . strip ( )
if not sid :
return bad ( handler , " session_id required " )
if not text :
return bad ( handler , " text required " )
2026-05-30 23:22:56 +00:00
evicted_cached_entry = None
2026-04-28 04:39:30 +00:00
with _cfg . SESSION_AGENT_CACHE_LOCK :
cached = _cfg . SESSION_AGENT_CACHE . get ( sid )
2026-05-30 16:13:46 +00:00
if cached :
agent = cached [ 0 ]
if not _cached_agent_matches_session ( agent , sid ) :
2026-05-30 23:22:56 +00:00
evicted_cached_entry = _cfg . SESSION_AGENT_CACHE . pop ( sid , None )
2026-05-30 16:13:46 +00:00
logger . warning (
' [webui] Evicted cached agent before steer due to mismatched session identity: cache_key= %s agent_session_id= %s ' ,
sid ,
_cached_agent_session_identity ( agent ) ,
)
cached = None
2026-05-30 23:22:56 +00:00
if evicted_cached_entry is not None :
try :
_close_cached_agent_entry_at_session_boundary ( sid , evicted_cached_entry )
except Exception :
logger . debug ( " Failed to close steer identity-mismatched cached agent for session %s " , sid , exc_info = True )
2026-04-26 02:21:00 +00:00
if not cached :
2026-06-22 06:22:51 +00:00
# No active agent for this session — caller surfaces a steer failure
# without cancelling the active run.
2026-04-26 02:21:00 +00:00
return j ( handler , { " accepted " : False , " fallback " : " no_cached_agent " ,
" stream_id " : None } )
agent = cached [ 0 ]
if not hasattr ( agent , " steer " ) :
# Older hermes-agent that pre-dates the steer() method
return j ( handler , { " accepted " : False , " fallback " : " agent_lacks_steer " ,
" stream_id " : None } )
# Verify the agent is currently running. Use the session's
# active_stream_id rather than calling load_session_locked() which
# would block on the streaming thread's lock.
try :
s = get_session ( sid )
except KeyError :
return j ( handler , { " accepted " : False , " fallback " : " session_not_found " ,
" stream_id " : None } )
active_stream_id = getattr ( s , " active_stream_id " , None ) or None
if not active_stream_id :
return j ( handler , { " accepted " : False , " fallback " : " not_running " ,
" stream_id " : None } )
2026-04-28 04:39:30 +00:00
with _cfg . STREAMS_LOCK :
stream_alive = active_stream_id in _cfg . STREAMS
2026-04-26 02:21:00 +00:00
if not stream_alive :
# Active stream id is stale — stream has ended; caller falls back
return j ( handler , { " accepted " : False , " fallback " : " stream_dead " ,
" stream_id " : None } )
try :
accepted = bool ( agent . steer ( text ) )
except Exception as exc :
logger . debug ( " agent.steer() raised for session= %s : %s " , sid , exc )
return j ( handler , { " accepted " : False , " fallback " : " steer_error " ,
" stream_id " : active_stream_id } )
return j ( handler , { " accepted " : accepted , " fallback " : None ,
" stream_id " : active_stream_id } )
2026-03-31 03:40:19 +00:00
def cancel_stream ( stream_id : str ) - > bool :
2026-06-03 18:26:12 +00:00
""" Signal an in-flight stream to cancel. Returns True if work was found.
2026-04-20 23:54:40 +00:00
Eagerly releases the session lock ( pops STREAMS / CANCEL_FLAGS / AGENT_INSTANCES
and clears session . active_stream_id ) so new / api / chat / start requests succeed
immediately after cancel , even if the agent thread is still blocked .
The worker thread ' s finally block uses .pop(key, None), so the double-pop is
a safe no - op . Session cleanup runs outside STREAMS_LOCK to preserve lock
ordering ( streaming thread does LOCK → STREAMS_LOCK ; inverting would deadlock ) .
"""
2026-04-28 04:39:30 +00:00
from api import config as _live_config
# Use module-level aliases (imported from api.config at startup).
# In production these are always the same objects as api.config.STREAMS etc.
# The fallback below handles a hypothetical future case where api.config's
# state dicts are replaced at runtime (e.g. a future profile-reload path).
# No production code currently does this; the fallback is defensive only.
streams = STREAMS
cancel_flags = CANCEL_FLAGS
agent_instances = AGENT_INSTANCES
partial_texts = STREAM_PARTIAL_TEXT
streams_lock = STREAMS_LOCK
if stream_id not in streams and getattr ( _live_config , ' STREAMS ' , streams ) is not streams :
streams = _live_config . STREAMS
cancel_flags = _live_config . CANCEL_FLAGS
agent_instances = _live_config . AGENT_INSTANCES
partial_texts = _live_config . STREAM_PARTIAL_TEXT
streams_lock = _live_config . STREAMS_LOCK
2026-06-03 18:26:12 +00:00
active_run_entry = None
active_run_session_id = None
stream_present = False
agent = None
q = None
# Snapshots captured UNDER streams_lock so the worker's finally block (which
# pops STREAM_PARTIAL_TEXT/REASONING/TOOL_CALLS under STREAMS_LOCK) cannot
# race agent.interrupt() and clear these buffers before we read them — a
# cancelled turn would otherwise silently lose its already-streamed
# partial text / reasoning / tool-calls (Codex pre-release finding).
_snap_partial_text = None
_snap_reasoning = None
_snap_tool_calls = None
_snap_flag = None
_snap_agent = None
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
2026-06-03 18:26:12 +00:00
with streams_lock :
stream_present = stream_id in streams
# Snapshot everything the worker's finally could pop, WHILE the lock is
# held and before any interrupt lets that finally run — for BOTH the
# STREAMS-present and the ACTIVE_RUNS-only (detached) paths. The buffers
# are keyed by stream_id independent of STREAMS membership, so a detached
# cancel must snapshot them too or it loses the already-streamed text.
_snap_flag = cancel_flags . get ( stream_id )
_snap_agent = agent_instances . get ( stream_id )
_snap_partial_text = partial_texts . get ( stream_id , ' ' )
if not _snap_partial_text :
_live_partials = getattr ( _live_config , ' STREAM_PARTIAL_TEXT ' , partial_texts )
if _live_partials is not partial_texts :
_snap_partial_text = _live_partials . get ( stream_id , ' ' )
_snap_reasoning = STREAM_REASONING_TEXT . get ( stream_id , ' ' )
if not _snap_reasoning :
_live_reasoning = getattr ( _live_config , ' STREAM_REASONING_TEXT ' , STREAM_REASONING_TEXT )
if _live_reasoning is not STREAM_REASONING_TEXT :
_snap_reasoning = _live_reasoning . get ( stream_id , ' ' )
_snap_tool_calls = list ( STREAM_LIVE_TOOL_CALLS . get ( stream_id , [ ] ) or [ ] )
if not _snap_tool_calls :
_live_tools = getattr ( _live_config , ' STREAM_LIVE_TOOL_CALLS ' , STREAM_LIVE_TOOL_CALLS )
if _live_tools is not STREAM_LIVE_TOOL_CALLS :
_snap_tool_calls = list ( _live_tools . get ( stream_id , [ ] ) or [ ] )
if stream_present :
q = streams . get ( stream_id )
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
else :
2026-06-03 18:26:12 +00:00
try :
with _live_config . ACTIVE_RUNS_LOCK :
active_run_entry = dict ( ( _live_config . ACTIVE_RUNS or { } ) . get ( stream_id ) or { } )
except Exception :
active_run_entry = None
if not active_run_entry :
return False
active_run_session_id = str ( active_run_entry . get ( " session_id " ) or " " ) . strip ( ) or None
if active_run_entry is None :
try :
with _live_config . ACTIVE_RUNS_LOCK :
active_run_entry = dict ( ( _live_config . ACTIVE_RUNS or { } ) . get ( stream_id ) or { } )
except Exception :
active_run_entry = None
if active_run_entry and not active_run_session_id :
active_run_session_id = str ( active_run_entry . get ( " session_id " ) or " " ) . strip ( ) or None
2026-06-19 19:34:24 +00:00
# Mark the worker lifecycle registry immediately. The SSE maps may be popped
# below while the worker is still unwinding; ACTIVE_RUNS is what recovery /
# health polling sees during that detached window.
update_active_run ( stream_id , phase = " cancelling " )
2026-06-03 18:26:12 +00:00
# Set WebUI layer cancel flag. Prefer the snapshot captured under the lock;
# fall back to a fresh lookup for the ACTIVE_RUNS-only path (stream absent).
flag = _snap_flag if _snap_flag is not None else cancel_flags . get ( stream_id )
if flag :
flag . set ( )
# Interrupt the AIAgent instance to stop tool execution. Use the
# lock-snapshot agent when the stream was present; otherwise fall back to
# the session agent cache via the active-run session id.
agent = _snap_agent if _snap_agent is not None else agent_instances . get ( stream_id )
if agent is None and active_run_session_id :
try :
with _live_config . SESSION_AGENT_CACHE_LOCK :
cached = _live_config . SESSION_AGENT_CACHE . get ( active_run_session_id )
if cached and _cached_agent_matches_session ( cached [ 0 ] , active_run_session_id ) :
agent = cached [ 0 ]
except Exception :
pass
if agent :
try :
agent . interrupt ( " Cancelled by user " )
except Exception as e :
# Log but don't block the cancel flow
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
import logging
logging . getLogger ( __name__ ) . debug (
2026-06-03 18:26:12 +00:00
f " Failed to interrupt agent for stream { stream_id } : { e } "
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
)
2026-06-03 18:26:12 +00:00
elif stream_present :
# Agent not yet stored - cancel_event flag will be checked by agent thread
import logging
logging . getLogger ( __name__ ) . debug (
f " Cancel requested for stream { stream_id } before agent ready - "
f " cancel_event flag set, will be checked on agent startup "
)
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
2026-06-03 18:26:12 +00:00
# Clear any pending clarify prompt so the blocked tool call can unwind.
try :
from api . clarify import clear_pending as _clear_clarify_pending
2026-04-15 05:10:50 +00:00
2026-06-03 18:26:12 +00:00
_clarify_session_id = getattr ( agent , " session_id " , None ) if agent else active_run_session_id
if _clarify_session_id :
_clear_clarify_pending ( _clarify_session_id )
except Exception :
logger . debug ( " Failed to clear clarify prompt during cancel " )
# Capture the queue while the stream still exists, but do not emit the
# terminal cancel event until the session cleanup below confirms the turn
# is still active. Otherwise a late Stop click can race with a successful
# worker save and show cancel in the client while persistence says done.
_emit_cancel_event = True
# ── Eager session lock release (fixes #653) ──────────────────────────
# Pop stream state now so the 409 guard in routes.py sees the session
# as idle and allows new /api/chat/start immediately after cancel,
# even if the agent thread is still blocked in a C-level syscall.
# The worker thread's finally block uses .pop(key, None) too, so a
# double-pop here is safe (no-op).
if stream_present :
2026-04-28 04:39:30 +00:00
streams . pop ( stream_id , None )
cancel_flags . pop ( stream_id , None )
agent_instances . pop ( stream_id , None )
2026-06-03 18:26:12 +00:00
# STREAM_PARTIAL_TEXT is intentionally NOT popped here — the agent thread may
# still be appending tokens, and the streaming finally block handles cleanup
# when the thread exits. We already snapshotted the buffers under streams_lock
# at the top of this function (see _snap_*), so they're safe to read below
# even if the worker's finally has since popped the live maps.
# Resolve the cancel session id and reuse the under-lock snapshots.
# Session cleanup (get_session + save) must happen OUTSIDE the lock —
# get_session() acquires LOCK, and the streaming thread does LOCK first
# then STREAMS_LOCK, so inverting the order here would cause deadlock.
_cancel_session_id = getattr ( agent , ' session_id ' , None ) if agent else None
if not _cancel_session_id and active_run_session_id :
_cancel_session_id = active_run_session_id
# Use the snapshots captured under streams_lock above (the worker's finally
# may have popped the live buffers by now via agent.interrupt()). For the
# ACTIVE_RUNS-only path (stream absent) the snapshots are None → fall back to
# a best-effort live read.
_cancel_partial_text = _snap_partial_text if _snap_partial_text is not None else partial_texts . get ( stream_id , ' ' )
if not _cancel_partial_text :
live_partials = getattr ( _live_config , ' STREAM_PARTIAL_TEXT ' , partial_texts )
if live_partials is not partial_texts :
_cancel_partial_text = live_partials . get ( stream_id , ' ' )
# Capture reasoning trace and live tool calls (#1361 §A + §B)
_cancel_reasoning = _snap_reasoning if _snap_reasoning is not None else STREAM_REASONING_TEXT . get ( stream_id , ' ' )
if not _cancel_reasoning :
live_reasoning = getattr ( _live_config , ' STREAM_REASONING_TEXT ' , STREAM_REASONING_TEXT )
if live_reasoning is not STREAM_REASONING_TEXT :
_cancel_reasoning = live_reasoning . get ( stream_id , ' ' )
_cancel_tool_calls = _snap_tool_calls if _snap_tool_calls is not None else STREAM_LIVE_TOOL_CALLS . get ( stream_id , [ ] )
if not _cancel_tool_calls :
live_tools = getattr ( _live_config , ' STREAM_LIVE_TOOL_CALLS ' , STREAM_LIVE_TOOL_CALLS )
if live_tools is not STREAM_LIVE_TOOL_CALLS :
_cancel_tool_calls = live_tools . get ( stream_id , [ ] )
2026-04-20 23:54:40 +00:00
# Session cleanup outside STREAMS_LOCK to preserve lock ordering.
2026-04-23 21:25:43 +00:00
# Acquire the per-session _agent_lock too, mirroring every other session
# writer (streaming success/error paths, periodic checkpoint, POST endpoints)
# so the cancel-path mutation races neither the checkpoint thread nor
# concurrent undo/retry calls.
2026-04-20 23:54:40 +00:00
if _cancel_session_id :
2026-04-23 21:25:43 +00:00
with _get_session_agent_lock ( _cancel_session_id ) :
try :
_cs = get_session ( _cancel_session_id )
2026-05-12 19:53:28 +00:00
if not isinstance ( getattr ( _cs , ' messages ' , None ) , list ) :
_cs . messages = [ ]
2026-05-12 11:55:31 +00:00
if not _stream_writeback_is_current ( _cs , stream_id ) :
2026-05-13 20:44:44 +00:00
# The stream has rotated to a different stream id (newer
# turn started, or the worker already finalized this one).
# Skip the cancel-marker append AND suppress the terminal
# cancel event so we don't contradict a possibly-already-
# delivered done payload (#2151 + #2154 / PR #2136).
2026-05-12 11:55:31 +00:00
logger . info (
" Skipping stale cancel writeback for session %s stream %s ; active_stream_id= %s " ,
_cancel_session_id ,
stream_id ,
getattr ( _cs , ' active_stream_id ' , None ) ,
)
2026-05-12 19:53:28 +00:00
_emit_cancel_event = False
2026-05-12 11:55:31 +00:00
return True
2026-04-30 16:18:41 +00:00
# ── Preserve the user's typed message before clearing pending state (#1298) ──
# The agent's internal messages list (where the user message was appended at
# the start of run_conversation()) may not have been merged back into
# _cs.messages yet — cancel_stream() races with the streaming thread's final
# _merge_display_messages_after_agent_result() call. Without this guard, the
# user's message is lost: pending_user_message gets cleared below, and
# _cs.messages still only contains messages from prior turns. The reporter
# of #1298 sees their typed text vanish from chat after clicking Stop.
#
# Recovery rule: if pending_user_message is set AND the latest message in
# _cs.messages isn't already a matching user turn, synthesize one. The
# match check guards against double-append when the streaming thread DID
# reach its merge step before cancel_stream() got the session lock.
#
# Wrapped in its own try/except so an unexpected _cs.messages shape (e.g.
# in unit tests using Mock sessions) cannot escape and skip the rest of
# the cleanup.
try :
_pending_user = getattr ( _cs , ' pending_user_message ' , None )
2026-06-18 22:33:29 +00:00
_pending_source = getattr ( _cs , ' pending_user_source ' , None )
2026-04-30 16:18:41 +00:00
_pending_atts_raw = getattr ( _cs , ' pending_attachments ' , None )
_pending_atts = list ( _pending_atts_raw ) if isinstance ( _pending_atts_raw , ( list , tuple ) ) else [ ]
2026-04-30 16:28:20 +00:00
_pending_started = getattr ( _cs , ' pending_started_at ' , None ) or 0
2026-04-30 16:18:41 +00:00
_msgs_for_recovery = _cs . messages if isinstance ( _cs . messages , list ) else None
if _pending_user and _msgs_for_recovery is not None :
_last_user = None
for _m in reversed ( _msgs_for_recovery ) :
if isinstance ( _m , dict ) and _m . get ( ' role ' ) == ' user ' :
_last_user = _m
break
_already_persisted = False
if _last_user is not None :
_last_content = _last_user . get ( ' content ' )
2026-04-30 16:28:20 +00:00
_last_ts = _last_user . get ( ' timestamp ' ) or 0
# Only treat as already-persisted if the latest user turn
# was created AT OR AFTER the current turn's pending_started_at.
# An earlier turn whose content happens to be a substring
# (e.g. prior reply was "ok", user now types "ok please continue")
# must NOT short-circuit synthesis — that would re-introduce
# the data-loss bug this guard is supposed to prevent.
if isinstance ( _last_content , str ) and _last_ts > = _pending_started :
2026-04-30 16:18:41 +00:00
# Tolerate the workspace prefix the streaming thread prepends.
2026-04-30 16:28:20 +00:00
if _pending_user == _last_content or _pending_user in _last_content :
2026-04-30 16:18:41 +00:00
_already_persisted = True
if not _already_persisted :
2026-06-18 22:33:29 +00:00
_recovered_ts = int ( time . time ( ) )
if isinstance ( _pending_started , ( int , float ) ) and _pending_started > 0 :
_recovered_ts = int ( _pending_started )
2026-04-30 16:18:41 +00:00
_user_turn : dict = {
' role ' : ' user ' ,
' content ' : _pending_user ,
2026-06-18 22:33:29 +00:00
' timestamp ' : _recovered_ts ,
2026-04-30 16:18:41 +00:00
}
2026-06-18 22:33:29 +00:00
if _pending_source and _pending_source != ' webui ' :
_user_turn [ ' _source ' ] = _pending_source
2026-04-30 16:18:41 +00:00
if _pending_atts :
_user_turn [ ' attachments ' ] = _pending_atts
_msgs_for_recovery . append ( _user_turn )
except Exception :
logger . debug (
" Failed to recover pending user message on cancel for %s " ,
_cancel_session_id ,
)
2026-04-23 21:25:43 +00:00
_cs . active_stream_id = None
_cs . pending_user_message = None
_cs . pending_attachments = [ ]
_cs . pending_started_at = None
2026-06-18 22:33:29 +00:00
_cs . pending_user_source = None
2026-04-23 21:25:43 +00:00
# Persist any partial assistant text that was streamed before cancel (#893).
# Preserving partial content means the user sees what the agent had
# produced rather than losing it entirely. The marker is _partial=True
# (for session/UI identification only) — NOT _error=True — so the partial
# content IS kept in the history sent to the agent on the next user
# message, letting the model continue from where it was cut off.
# See the inner comment on the append call below for the rationale.
2026-04-30 23:24:29 +00:00
#
# #1361: Also persist reasoning trace and live tool calls that were
# accumulated in thread-local variables but invisible to the cancel path.
# This prevents paid-token data loss when cancelling mid-reasoning or
# mid-tool-execution.
2026-06-10 19:05:48 +00:00
# NOTE on _partial_tool_calls: the captured entries use the WebUI
# internal shape {name, args, done, duration, is_error} — they do
# NOT carry the OpenAI/Anthropic API id + function: {name, arguments}
# envelope. Storing under 'tool_calls' would cause
# _sanitize_messages_for_api to forward them to the next-turn LLM
# call and strict providers would 400 on the malformed entries.
# The underscore-prefixed key is not in the whitelist, so sanitize
# strips it. The UI reads it via static/messages.js. (v0.50.251.)
_partial_msg = _build_partial_message (
_cancel_partial_text , _cancel_reasoning , _cancel_tool_calls ,
)
2026-05-12 19:53:28 +00:00
_cancel_marker_exists = _session_has_cancel_marker ( _cs )
_cancel_marker_idx = len ( _cs . messages )
if _cancel_marker_exists :
for _idx in range ( len ( _cs . messages ) - 1 , - 1 , - 1 ) :
_m = _cs . messages [ _idx ]
if not isinstance ( _m , dict ) or _m . get ( ' role ' ) != ' assistant ' :
continue
_content = str ( _m . get ( ' content ' ) or ' ' ) . strip ( ) . lower ( )
2026-05-12 22:43:36 +00:00
if any ( pattern in _content for pattern in _CANCEL_MARKER_PATTERNS ) :
2026-05-12 19:53:28 +00:00
_cancel_marker_idx = _idx
break
2026-06-10 19:05:48 +00:00
if _partial_msg is not None :
2026-05-19 18:15:35 +00:00
# Deduplicate against the full partial payload, not just
# non-empty content. Tool-only/reasoning-only partials have
# empty content, so a content-gated check can append the same
# failed turn repeatedly during cancel/replay recovery (#2592).
if not _partial_marker_already_present (
_cs . messages ,
_partial_msg ,
before_idx = _cancel_marker_idx ,
) :
_cs . messages . insert ( _cancel_marker_idx , _partial_msg )
2026-04-23 21:25:43 +00:00
# Cancel marker — flagged _error=True so it is stripped from conversation
# history on the next turn (prevents model from seeing "Task cancelled."
# as a prior assistant reply).
2026-05-12 19:53:28 +00:00
if not _cancel_marker_exists :
_cs . messages . append ( {
' role ' : ' assistant ' ,
2026-05-19 13:06:19 +00:00
' content ' : _cancelled_turn_content (
' Task cancelled. ' ,
_preferred_agent_display_name_for_session ( _cs ) ,
) ,
2026-05-12 19:53:28 +00:00
' _error ' : True ,
' provider_details ' : ' Task cancelled. ' ,
' provider_details_label ' : ' Cancellation details ' ,
' timestamp ' : int ( time . time ( ) ) ,
} )
2026-04-23 21:25:43 +00:00
_cs . save ( )
except Exception :
logger . debug ( " Failed to clear session state on cancel for %s " , _cancel_session_id )
2026-04-20 23:54:40 +00:00
2026-05-12 19:53:28 +00:00
if _emit_cancel_event and q :
2026-06-06 19:12:37 +00:00
_cancel_event_id = STREAM_LAST_EVENT_ID . get ( stream_id )
if _cancel_event_id and hasattr ( q , " note_last_event_id " ) :
try :
q . note_last_event_id ( _cancel_event_id )
except Exception :
logger . debug ( " Failed to note cancel event_id %s for stream %s " , _cancel_event_id , stream_id , exc_info = True )
2026-05-12 19:53:28 +00:00
try :
q . put_nowait ( ( ' cancel ' , { ' message ' : ' Cancelled by user ' } ) )
except Exception :
logger . debug ( " Failed to put cancel event to queue " )
2026-03-31 03:40:19 +00:00
return True