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-23 21:25:43 +00:00
|
|
|
import contextlib
|
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-03-31 03:40:19 +00:00
|
|
|
import os
|
|
|
|
|
import queue
|
2026-04-15 14:50:59 +00:00
|
|
|
import re
|
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-04-23 18:16:59 +00:00
|
|
|
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, STREAM_PARTIAL_TEXT,
|
2026-04-05 01:46:34 +00:00
|
|
|
LOCK, SESSIONS, SESSION_DIR,
|
2026-03-31 03:40:19 +00:00
|
|
|
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
|
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-03-31 03:40:19 +00:00
|
|
|
)
|
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
|
|
|
from api.helpers import redact_session_data
|
2026-03-31 03:40:19 +00:00
|
|
|
|
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
|
|
|
|
|
# save/restore around the entire agent run.
|
|
|
|
|
_ENV_LOCK = threading.Lock()
|
|
|
|
|
|
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
|
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.
|
|
|
|
|
_API_SAFE_MSG_KEYS = {'role', 'content', 'tool_calls', 'tool_call_id', 'name', 'refusal'}
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
s = re.sub(r'<think>.*?</think>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
2026-04-15 23:59:28 +00:00
|
|
|
s = re.sub(r'<\|channel\|>thought.*?<channel\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL)
|
2026-04-18 06:45:39 +00:00
|
|
|
s = re.sub(r'<\|turn\|>thinking\n.*?<turn\|>', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4
|
2026-04-15 14:50:59 +00:00
|
|
|
s = re.sub(r'^\s*(the|ther)\s+user\s+is\s+asking.*$', ' ', s, flags=re.IGNORECASE | re.MULTILINE)
|
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.
|
|
|
|
|
"""
|
|
|
|
|
if not text or '<function_calls>' not in text.lower():
|
|
|
|
|
return text
|
|
|
|
|
s = str(text)
|
|
|
|
|
# Strip complete blocks (possibly multiple)
|
|
|
|
|
s = re.sub(r'<function_calls>.*?</function_calls>', '', s, flags=re.IGNORECASE | re.DOTALL)
|
|
|
|
|
# Strip orphaned opening tags (stream cut off before closing tag)
|
|
|
|
|
s = re.sub(r'<function_calls>.*$', '', s, flags=re.IGNORECASE | re.DOTALL)
|
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
return current == candidate or candidate.startswith(current)
|
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\nAssistant answer:\n{assistant_text[:500]}"
|
|
|
|
|
prompts = [
|
|
|
|
|
(
|
|
|
|
|
"Generate a short session title from this conversation start.\n"
|
|
|
|
|
"Use BOTH the user's question and the assistant's visible answer.\n"
|
|
|
|
|
"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"
|
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _title_completion_budget(provider: str = '', model: str = '', base_url: str = '') -> int:
|
|
|
|
|
if _is_minimax_route(provider, model, base_url):
|
|
|
|
|
return 384
|
|
|
|
|
return 160
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
max_tokens = _title_completion_budget(provider, model, base_url)
|
|
|
|
|
reasoning_extra = {"reasoning": {"enabled": False}}
|
|
|
|
|
if _is_minimax_route(provider, model, base_url):
|
|
|
|
|
reasoning_extra["reasoning_split"] = True
|
|
|
|
|
try:
|
|
|
|
|
from agent.auxiliary_client import call_llm
|
|
|
|
|
for idx, prompt in enumerate(prompts):
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": prompt},
|
|
|
|
|
{"role": "user", "content": qa},
|
|
|
|
|
]
|
|
|
|
|
try:
|
|
|
|
|
resp = call_llm(
|
|
|
|
|
task='title_generation',
|
|
|
|
|
provider=provider or None,
|
|
|
|
|
model=model or None,
|
|
|
|
|
base_url=base_url or None,
|
|
|
|
|
messages=messages,
|
|
|
|
|
max_tokens=max_tokens,
|
|
|
|
|
temperature=0.2,
|
|
|
|
|
timeout=15.0,
|
|
|
|
|
extra_body=reasoning_extra,
|
|
|
|
|
)
|
|
|
|
|
raw = ''
|
|
|
|
|
try:
|
|
|
|
|
raw = resp.choices[0].message.content or ''
|
|
|
|
|
except Exception:
|
|
|
|
|
raw = ''
|
|
|
|
|
raw = str(raw or '').strip()
|
|
|
|
|
if raw:
|
|
|
|
|
return raw, ('llm_aux' if idx == 0 else 'llm_aux_retry')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug("Aux title generation attempt %s failed: %s", idx + 1, e)
|
|
|
|
|
return None, 'llm_error_aux'
|
|
|
|
|
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)
|
|
|
|
|
max_tokens = _title_completion_budget(
|
|
|
|
|
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},
|
|
|
|
|
]
|
|
|
|
|
try:
|
|
|
|
|
raw = ""
|
|
|
|
|
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 ''
|
|
|
|
|
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 ''
|
|
|
|
|
else:
|
|
|
|
|
api_kwargs = agent._build_api_kwargs(api_messages)
|
|
|
|
|
api_kwargs.pop('tools', None)
|
|
|
|
|
api_kwargs['temperature'] = 0.1
|
|
|
|
|
api_kwargs['timeout'] = 15.0
|
|
|
|
|
if _is_minimax_route(getattr(agent, 'provider', ''), getattr(agent, 'model', ''), getattr(agent, 'base_url', '')):
|
|
|
|
|
extra_body = dict(api_kwargs.get('extra_body') or {})
|
|
|
|
|
extra_body['reasoning_split'] = True
|
|
|
|
|
api_kwargs['extra_body'] = extra_body
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
raw = resp.choices[0].message.content or ""
|
|
|
|
|
except Exception:
|
|
|
|
|
raw = ""
|
|
|
|
|
raw = str(raw or '').strip()
|
|
|
|
|
if raw:
|
|
|
|
|
return raw, ('llm' if idx == 0 else 'llm_retry')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
return None, 'llm_error'
|
|
|
|
|
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:
|
|
|
|
|
return title, status, ''
|
|
|
|
|
return None, 'llm_invalid', str(raw)[:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_llm_session_title_via_aux(user_text: str, assistant_text: str, agent=None) -> tuple[Optional[str], str, str]:
|
|
|
|
|
"""Generate a title via dedicated auxiliary LLM route, then sanitize/validate result."""
|
|
|
|
|
raw, status = generate_title_raw_via_aux(
|
|
|
|
|
user_text,
|
|
|
|
|
assistant_text,
|
|
|
|
|
provider=getattr(agent, 'provider', '') if agent else '',
|
|
|
|
|
model=getattr(agent, 'model', '') if agent else '',
|
|
|
|
|
base_url=getattr(agent, 'base_url', '') if agent else '',
|
|
|
|
|
)
|
|
|
|
|
if not raw:
|
|
|
|
|
return None, status, ''
|
|
|
|
|
title = _sanitize_generated_title(raw)
|
|
|
|
|
if title:
|
|
|
|
|
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
|
|
|
|
|
user_text = re.sub(r'^\[Workspace:[^\]]+\]\s*', '', user_text)
|
|
|
|
|
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-04-23 16:45:34 +00:00
|
|
|
tokens = re.findall(r'[A-Za-z0-9][A-Za-z0-9_./+-]*', 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
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
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
|
|
|
|
|
# Prefer the active session model when available so title generation
|
|
|
|
|
# matches the user's chosen runtime and can use provider-specific fixes.
|
|
|
|
|
if agent:
|
|
|
|
|
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
|
|
|
|
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)
|
|
|
|
|
else:
|
|
|
|
|
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
|
|
|
|
|
source = llm_status
|
|
|
|
|
if not next_title:
|
|
|
|
|
next_title = _fallback_title_from_exchange(user_text, assistant_text)
|
|
|
|
|
if next_title:
|
|
|
|
|
logger.debug("Using local fallback for session title generation")
|
|
|
|
|
source = 'fallback'
|
2026-04-23 21:25:43 +00:00
|
|
|
wrote_title = False
|
|
|
|
|
effective_title = current
|
|
|
|
|
if next_title:
|
|
|
|
|
# Hold _agent_lock only for in-memory mutation + save so title write
|
|
|
|
|
# is serialized with checkpoint saves, cancel_stream, and other
|
|
|
|
|
# session-mutating endpoints. The LLM round-trip above ran outside
|
|
|
|
|
# the lock to avoid blocking other writers.
|
|
|
|
|
with _get_session_agent_lock(session_id):
|
|
|
|
|
# Stale-object guard: rebind to the canonical cached Session
|
|
|
|
|
# instance under LOCK before checking whether a user rename
|
|
|
|
|
# landed while the LLM title request was in-flight.
|
|
|
|
|
with LOCK:
|
|
|
|
|
s = SESSIONS.get(session_id, s)
|
|
|
|
|
effective_title = str(s.title or '').strip()
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
if not still_auto:
|
|
|
|
|
_put_title_status(put_event, session_id, 'skipped', 'manual_title', effective_title)
|
|
|
|
|
return
|
|
|
|
|
if next_title != effective_title:
|
|
|
|
|
s.title = next_title
|
|
|
|
|
s.llm_title_generated = True
|
|
|
|
|
# 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':
|
2026-04-23 21:25:43 +00:00
|
|
|
_put_title_status(put_event, session_id, source, 'local_summary', 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})
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 05:13:12 +00:00
|
|
|
def _sanitize_messages_for_api(messages):
|
|
|
|
|
"""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-04-04 05:13:12 +00:00
|
|
|
"""
|
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 = []
|
|
|
|
|
for msg in messages:
|
|
|
|
|
if not isinstance(msg, dict):
|
|
|
|
|
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
|
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}
|
|
|
|
|
if sanitized.get('role'):
|
|
|
|
|
clean.append(sanitized)
|
|
|
|
|
return clean
|
|
|
|
|
|
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
|
|
|
|
|
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}
|
|
|
|
|
if sanitized.get('role'):
|
|
|
|
|
out.append((idx, sanitized))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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')}
|
|
|
|
|
|
|
|
|
|
def _reasoning_only_assistant(msg):
|
|
|
|
|
if not isinstance(msg, dict) or msg.get('role') != 'assistant' or not msg.get('reasoning'):
|
|
|
|
|
return False
|
|
|
|
|
if msg.get('tool_calls'):
|
|
|
|
|
return False
|
|
|
|
|
return not _message_text(msg.get('content'))
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
if _reasoning_only_assistant(prev_msg):
|
|
|
|
|
updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))
|
|
|
|
|
safe_pos += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
safe_pos += 1
|
|
|
|
|
return updated_messages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tool_result_snippet(raw) -> str:
|
|
|
|
|
"""Extract a compact result preview from a stored tool message payload."""
|
|
|
|
|
text = str(raw or '')
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
return str(data.get('output') or data.get('result') or data.get('error') or text)[:200]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return text[:200]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-03-31 03:40:19 +00:00
|
|
|
def _sse(handler, event, data):
|
|
|
|
|
"""Write one SSE event to the response stream."""
|
|
|
|
|
payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
|
|
|
handler.wfile.write(payload.encode('utf-8'))
|
|
|
|
|
handler.wfile.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
|
|
|
|
|
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
|
|
|
|
q = STREAMS.get(stream_id)
|
|
|
|
|
if q is None:
|
|
|
|
|
return
|
2026-04-16 17:20:42 +00:00
|
|
|
s = None
|
|
|
|
|
_rt = {}
|
|
|
|
|
old_cwd = None
|
|
|
|
|
old_exec_ask = None
|
|
|
|
|
old_session_key = None
|
|
|
|
|
old_hermes_home = None
|
2026-03-31 03:40:19 +00:00
|
|
|
|
2026-04-15 05:10:50 +00:00
|
|
|
# ── MCP Server Discovery (lazy import, idempotent) ──
|
|
|
|
|
# discover_mcp_tools() is called here (rather than at server startup) so that
|
|
|
|
|
# the hermes-agent package is fully initialized before we try to connect.
|
|
|
|
|
# It is safe to call multiple times — already-connected servers are skipped.
|
|
|
|
|
try:
|
|
|
|
|
from tools.mcp_tool import discover_mcp_tools
|
|
|
|
|
discover_mcp_tools()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass # MCP not available or not configured — non-fatal
|
|
|
|
|
|
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-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
|
|
|
|
|
try:
|
|
|
|
|
q.put_nowait((event, data))
|
|
|
|
|
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-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:
|
|
|
|
|
s = get_session(session_id)
|
|
|
|
|
s.workspace = str(Path(workspace).expanduser().resolve())
|
|
|
|
|
s.model = model
|
|
|
|
|
|
|
|
|
|
_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():
|
|
|
|
|
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-04-21 16:16:51 +00:00
|
|
|
from api.profiles import get_hermes_home_for_profile
|
|
|
|
|
_profile_home = str(get_hermes_home_for_profile(getattr(s, 'profile', None)))
|
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-03-31 03:40:19 +00:00
|
|
|
_set_thread_env(
|
|
|
|
|
TERMINAL_CWD=str(s.workspace),
|
|
|
|
|
HERMES_EXEC_ASK='1',
|
|
|
|
|
HERMES_SESSION_KEY=session_id,
|
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
|
|
|
HERMES_HOME=_profile_home,
|
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:
|
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')
|
|
|
|
|
old_hermes_home = os.environ.get('HERMES_HOME')
|
|
|
|
|
os.environ['TERMINAL_CWD'] = str(s.workspace)
|
|
|
|
|
os.environ['HERMES_EXEC_ASK'] = '1'
|
|
|
|
|
os.environ['HERMES_SESSION_KEY'] = session_id
|
|
|
|
|
if _profile_home:
|
|
|
|
|
os.environ['HERMES_HOME'] = _profile_home
|
2026-04-08 14:22:39 +00:00
|
|
|
# Lock released — agent runs without holding it
|
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
|
|
|
|
|
try:
|
|
|
|
|
from tools.approval import (
|
|
|
|
|
register_gateway_notify as _reg_notify,
|
|
|
|
|
unregister_gateway_notify as _unreg_notify,
|
|
|
|
|
)
|
|
|
|
|
def _approval_notify_cb(approval_data):
|
|
|
|
|
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."""
|
|
|
|
|
timeout = 120
|
|
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
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-04-14 20:56:53 +00:00
|
|
|
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
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-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
|
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-04-13 23:18:15 +00:00
|
|
|
def on_reasoning(text):
|
2026-04-14 20:56:53 +00:00
|
|
|
nonlocal _reasoning_text
|
2026-04-13 23:18:15 +00:00
|
|
|
if text is None:
|
|
|
|
|
return
|
2026-04-14 20:56:53 +00:00
|
|
|
_reasoning_text += str(text)
|
2026-04-13 23:18:15 +00:00
|
|
|
put('reasoning', {'text': str(text)})
|
|
|
|
|
|
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-04-13 23:18:15 +00:00
|
|
|
def on_tool(*cb_args, **cb_kwargs):
|
|
|
|
|
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:
|
|
|
|
|
put('reasoning', {'text': str(reason_text)})
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
args_snap = {}
|
|
|
|
|
if isinstance(args, dict):
|
|
|
|
|
for k, v in list(args.items())[:4]:
|
2026-04-13 23:18:15 +00:00
|
|
|
s2 = str(v)
|
|
|
|
|
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
|
|
|
|
|
|
|
|
|
|
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-13 23:18:15 +00:00
|
|
|
put('tool', {
|
|
|
|
|
'event_type': event_type or 'tool.started',
|
|
|
|
|
'name': name,
|
|
|
|
|
'preview': preview,
|
|
|
|
|
'args': args_snap,
|
|
|
|
|
})
|
|
|
|
|
# Fallback: poll for pending approval in case notify_cb wasn't
|
|
|
|
|
# registered (e.g. older approval module without gateway support).
|
|
|
|
|
try:
|
|
|
|
|
from tools.approval import has_pending as _has_pending, _pending, _lock
|
|
|
|
|
if _has_pending(session_id):
|
|
|
|
|
with _lock:
|
|
|
|
|
p = dict(_pending.get(session_id, {}))
|
|
|
|
|
if p:
|
|
|
|
|
put('approval', p)
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
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-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)),
|
|
|
|
|
})
|
|
|
|
|
return
|
2026-03-31 03:40:19 +00:00
|
|
|
|
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:
|
2026-03-31 03:40:19 +00:00
|
|
|
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
|
2026-04-13 17:53:58 +00:00
|
|
|
|
|
|
|
|
# Initialize SessionDB so session_search works in WebUI sessions
|
|
|
|
|
_session_db = None
|
|
|
|
|
try:
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
_session_db = SessionDB()
|
|
|
|
|
except Exception as _db_err:
|
|
|
|
|
print(f"[webui] WARNING: SessionDB init failed — session_search will be unavailable: {_db_err}", flush=True)
|
2026-04-02 06:15:04 +00:00
|
|
|
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
|
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:
|
|
|
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
2026-04-06 21:10:26 +00:00
|
|
|
_rt = resolve_runtime_provider(requested=resolved_provider)
|
2026-04-04 20:03:02 +00:00
|
|
|
resolved_api_key = _rt.get("api_key")
|
|
|
|
|
if not resolved_provider:
|
|
|
|
|
resolved_provider = _rt.get("provider")
|
|
|
|
|
if not resolved_base_url:
|
|
|
|
|
resolved_base_url = _rt.get("base_url")
|
|
|
|
|
except Exception as _e:
|
|
|
|
|
print(f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True)
|
|
|
|
|
|
2026-04-03 20:34:52 +00:00
|
|
|
# Read per-profile config at call time (not module-level snapshot)
|
|
|
|
|
from api.config import get_config as _get_config
|
|
|
|
|
_cfg = _get_config()
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
# Fallback model from profile config (e.g. for rate-limit recovery)
|
|
|
|
|
_fallback = _cfg.get('fallback_model') or None
|
|
|
|
|
if _fallback:
|
|
|
|
|
# Resolve the fallback through our provider logic too
|
|
|
|
|
fb_model = _fallback.get('model', '')
|
|
|
|
|
fb_provider = _fallback.get('provider', '')
|
|
|
|
|
fb_base_url = _fallback.get('base_url')
|
|
|
|
|
_fallback_resolved = {
|
|
|
|
|
'model': fb_model,
|
|
|
|
|
'provider': fb_provider,
|
|
|
|
|
'base_url': fb_base_url,
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
_fallback_resolved = None
|
|
|
|
|
|
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-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:
|
|
|
|
|
from api.config import parse_reasoning_effort as _parse_reff
|
|
|
|
|
_effort_cfg = _cfg.cfg.get('agent', {}) if isinstance(_cfg.cfg, dict) else {}
|
|
|
|
|
_effort_raw = _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None
|
|
|
|
|
_reasoning_config = _parse_reff(_effort_raw)
|
|
|
|
|
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-03-31 03:40:19 +00:00
|
|
|
platform='cli',
|
|
|
|
|
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-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-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
|
|
|
|
|
|
|
|
agent = _AIAgent(**_agent_kwargs)
|
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")
|
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.
|
|
|
|
|
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
|
|
|
|
workspace_system_msg = (
|
|
|
|
|
f"Active workspace at session start: {s.workspace}\n"
|
|
|
|
|
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
|
|
|
|
|
"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 "
|
|
|
|
|
"[Workspace: ...] tag as your default working directory for ALL file operations: "
|
|
|
|
|
"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)
|
|
|
|
|
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
|
|
|
|
if _personality_prompt:
|
|
|
|
|
agent.ephemeral_system_prompt = _personality_prompt
|
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
|
|
|
_previous_messages = list(s.messages or [])
|
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:
|
|
|
|
|
s.save(skip_index=True)
|
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()
|
|
|
|
|
_ckpt_thread = threading.Thread(
|
|
|
|
|
target=_periodic_checkpoint, daemon=True,
|
|
|
|
|
name=f"ckpt-{session_id[:8]}",
|
|
|
|
|
)
|
|
|
|
|
_ckpt_thread.start()
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
result = agent.run_conversation(
|
|
|
|
|
user_message=workspace_ctx + msg_text,
|
2026-04-06 21:10:30 +00:00
|
|
|
system_message=workspace_system_msg,
|
2026-04-04 05:13:12 +00:00
|
|
|
conversation_history=_sanitize_messages_for_api(s.messages),
|
2026-03-31 03:40:19 +00:00
|
|
|
task_id=session_id,
|
|
|
|
|
persist_user_message=msg_text,
|
|
|
|
|
)
|
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)
|
|
|
|
|
with _agent_lock:
|
|
|
|
|
s.messages = _restore_reasoning_metadata(
|
|
|
|
|
_previous_messages,
|
|
|
|
|
result.get('messages') or s.messages,
|
2026-04-20 22:48:19 +00:00
|
|
|
)
|
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'])
|
|
|
|
|
|
|
|
|
|
# ── 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.
|
|
|
|
|
_assistant_added = any(
|
|
|
|
|
m.get('role') == 'assistant' and str(m.get('content') or '').strip()
|
|
|
|
|
for m in (result.get('messages') or [])
|
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-04-23 21:25:43 +00:00
|
|
|
# _token_sent tracks whether on_token() was called (any streamed text)
|
|
|
|
|
if not _assistant_added and not _token_sent:
|
|
|
|
|
_last_err = getattr(agent, '_last_error', None) or result.get('error') or ''
|
|
|
|
|
_err_str = str(_last_err) if _last_err else ''
|
|
|
|
|
_err_lower = _err_str.lower()
|
|
|
|
|
_is_quota = (
|
|
|
|
|
'insufficient credit' in _err_lower
|
|
|
|
|
or 'credit balance' in _err_lower
|
|
|
|
|
or 'credits exhausted' in _err_lower
|
|
|
|
|
or 'quota_exceeded' in _err_lower
|
|
|
|
|
or 'quota exceeded' in _err_lower
|
|
|
|
|
or 'exceeded your current quota' in _err_lower
|
2026-04-20 22:48:19 +00:00
|
|
|
)
|
2026-04-23 21:25:43 +00:00
|
|
|
_is_auth = (
|
|
|
|
|
not _is_quota and (
|
|
|
|
|
'401' in _err_str
|
|
|
|
|
or (_last_err and 'AuthenticationError' in type(_last_err).__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
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if _is_quota:
|
|
|
|
|
_err_label = 'Out of credits'
|
|
|
|
|
_err_type = 'quota_exhausted'
|
|
|
|
|
_err_hint = 'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.'
|
|
|
|
|
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.'
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
_err_label = 'No response received'
|
|
|
|
|
_err_type = 'no_response'
|
|
|
|
|
_err_hint = 'Verify your API key is valid and the selected model is available for your account.'
|
|
|
|
|
put('apperror', {
|
|
|
|
|
'message': _err_str or f'{_err_label}.',
|
|
|
|
|
'type': _err_type,
|
|
|
|
|
'hint': _err_hint,
|
|
|
|
|
})
|
|
|
|
|
# Clear stream/pending state so the session does not appear
|
|
|
|
|
# "agent_running" on reload after a silent failure.
|
|
|
|
|
# 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.
|
|
|
|
|
s.active_stream_id = None
|
|
|
|
|
s.pending_user_message = None
|
|
|
|
|
s.pending_attachments = []
|
|
|
|
|
s.pending_started_at = None
|
|
|
|
|
s.messages.append({
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*',
|
|
|
|
|
'timestamp': int(time.time()),
|
|
|
|
|
'_error': True,
|
|
|
|
|
})
|
|
|
|
|
try:
|
|
|
|
|
s.save()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return # apperror already closes the stream on the client side
|
|
|
|
|
|
|
|
|
|
# ── Handle context compression side effects ──
|
|
|
|
|
# If compression fired inside run_conversation, the agent may have
|
|
|
|
|
# rotated its session_id. Detect and fix the mismatch so the WebUI
|
|
|
|
|
# continues writing to the correct session file.
|
|
|
|
|
#
|
|
|
|
|
# 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.
|
|
|
|
|
_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
|
|
|
|
|
# Rename the session file
|
|
|
|
|
old_path = SESSION_DIR / f'{old_sid}.json'
|
|
|
|
|
new_path = SESSION_DIR / f'{new_sid}.json'
|
|
|
|
|
s.session_id = new_sid
|
|
|
|
|
with LOCK:
|
|
|
|
|
if old_sid in SESSIONS:
|
|
|
|
|
SESSIONS[new_sid] = SESSIONS.pop(old_sid)
|
|
|
|
|
# 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)
|
|
|
|
|
if old_path.exists() and not new_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
old_path.rename(new_path)
|
|
|
|
|
except OSError:
|
|
|
|
|
logger.debug("Failed to rename session file during compression")
|
|
|
|
|
_compressed = True
|
|
|
|
|
# Also detect compression via the result dict or compressor state
|
|
|
|
|
if not _compressed:
|
|
|
|
|
_compressor = getattr(agent, 'context_compressor', None)
|
|
|
|
|
if _compressor and getattr(_compressor, 'compression_count', 0) > 0:
|
|
|
|
|
_compressed = True
|
|
|
|
|
# Notify the frontend that compression happened
|
|
|
|
|
if _compressed:
|
|
|
|
|
put('compressed', {
|
|
|
|
|
'message': 'Context auto-compressed to continue the conversation',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Stamp 'timestamp' on any messages that don't have one yet
|
|
|
|
|
_now = time.time()
|
|
|
|
|
for _m in s.messages:
|
|
|
|
|
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
|
|
|
|
|
_m['timestamp'] = int(_now)
|
|
|
|
|
# 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)
|
|
|
|
|
# Read token/cost usage from the agent object (if available)
|
|
|
|
|
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)
|
|
|
|
|
s.input_tokens = (s.input_tokens or 0) + input_tokens
|
|
|
|
|
s.output_tokens = (s.output_tokens or 0) + output_tokens
|
|
|
|
|
if estimated_cost:
|
|
|
|
|
s.estimated_cost = (s.estimated_cost or 0) + estimated_cost
|
|
|
|
|
# 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-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:
|
|
|
|
|
for m in reversed(s.messages):
|
|
|
|
|
if m.get('role') == 'user':
|
|
|
|
|
content = str(m.get('content', ''))
|
|
|
|
|
# Match if content is part of the sent message or vice-versa
|
|
|
|
|
base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text
|
|
|
|
|
if base_text[:60] in content or content[:60] in msg_text:
|
|
|
|
|
m['attachments'] = attachments
|
|
|
|
|
break
|
|
|
|
|
# Persist reasoning trace in the session so it survives reload.
|
|
|
|
|
# Must run BEFORE s.save() — otherwise the mutation lives only in
|
|
|
|
|
# memory until the next turn's save, and the last-turn thinking card
|
|
|
|
|
# is lost when the user reloads immediately after a response.
|
|
|
|
|
if _reasoning_text and s.messages:
|
|
|
|
|
for _rm in reversed(s.messages):
|
|
|
|
|
if isinstance(_rm, dict) and _rm.get('role') == 'assistant':
|
|
|
|
|
_rm['reasoning'] = _reasoning_text
|
2026-03-31 03:40:19 +00:00
|
|
|
break
|
2026-04-23 21:25:43 +00:00
|
|
|
s.save()
|
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-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")
|
feat: Sprint 23 — agentic transparency + polish
Track A: Token/cost display
- Read agent usage attrs (session_prompt_tokens, session_completion_tokens,
session_estimated_cost_usd) after run_conversation in streaming.py
- Add input_tokens, output_tokens, estimated_cost fields to Session model
- Include usage in done SSE event payload
- Store usage on S.lastUsage in messages.js done handler
- Render usage badge below last assistant message (input/output/cost)
Track B: Subagent delegation cards
- Add subagent_progress to toolIcon map with shuffle emoji
- Special-case subagent_progress in buildToolCard: "Subagent" label,
strip double emoji from preview, add tool-card-subagent CSS class
- Indented border-left styling for subagent cards
- Clean delegate_task display name
Track C: Skill picker in cron create form
- Add skill search input + tag chips to cron create form HTML
- Skill picker JS in panels.js: search/filter, click-to-add tags,
remove tag chips, pre-fetch skill list on form open
- submitCronCreate sends skills array in POST body
- Skill picker dropdown + tag CSS
Track D: Skill linked files viewer
- Add file query param to /api/skills/content endpoint
- Serve linked files from skill directory with path traversal protection
- Ensure linked_files key always present in skill content response
- Render linked files section below SKILL.md content in preview panel
- openSkillFile function for viewing individual linked files
Track E: Bug fixes and code quality
- Expand Session.__init__ and compact() to readable multi-line format
- Remove inline import json as _j2 inside loop in streaming.py
- Fix tool_calls: capture args from assistant messages, skip unresolved names
- Store args snapshot in persisted tool_calls for reload display
6 new tests. Total: 421 (409 passing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:33:49 +00:00
|
|
|
usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost}
|
2026-04-05 01:50:17 +00:00
|
|
|
# Include context window data from the agent's compressor for the UI indicator
|
|
|
|
|
_cc = getattr(agent, 'context_compressor', None)
|
|
|
|
|
if _cc:
|
|
|
|
|
usage['context_length'] = getattr(_cc, 'context_length', 0) or 0
|
|
|
|
|
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
|
|
|
|
|
usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
|
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())
|
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
|
|
|
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
|
|
|
|
|
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
|
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})
|
2026-04-08 14:22:39 +00:00
|
|
|
finally:
|
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-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-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
|
|
|
|
|
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)
|
|
|
|
|
_exc_lower = err_str.lower()
|
|
|
|
|
# Classify before saving so the error message can be persisted to the session.
|
|
|
|
|
# Check quota exhaustion first — OpenAI billing 429s use insufficient_quota which
|
|
|
|
|
# also matches rate-limit patterns, so order matters.
|
|
|
|
|
_exc_is_quota = (
|
|
|
|
|
'insufficient credit' in _exc_lower
|
|
|
|
|
or 'credit balance' in _exc_lower
|
|
|
|
|
or 'credits exhausted' in _exc_lower
|
|
|
|
|
or 'quota_exceeded' in _exc_lower
|
|
|
|
|
or 'quota exceeded' in _exc_lower
|
|
|
|
|
or 'exceeded your current quota' in _exc_lower
|
|
|
|
|
)
|
|
|
|
|
_exc_is_rate_limit = (not _exc_is_quota) and (
|
|
|
|
|
'rate limit' in _exc_lower or '429' in err_str or 'RateLimitError' in type(e).__name__
|
|
|
|
|
)
|
|
|
|
|
_exc_is_auth = (
|
|
|
|
|
'401' in err_str
|
|
|
|
|
or 'AuthenticationError' in type(e).__name__
|
|
|
|
|
or 'authentication' in _exc_lower
|
|
|
|
|
or 'unauthorized' in _exc_lower
|
|
|
|
|
or 'invalid api key' in _exc_lower
|
|
|
|
|
or 'no cookie auth credentials' in _exc_lower
|
|
|
|
|
)
|
|
|
|
|
if _exc_is_quota:
|
|
|
|
|
_exc_label, _exc_type, _exc_hint = (
|
|
|
|
|
'Out of credits', 'quota_exhausted',
|
|
|
|
|
'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.',
|
|
|
|
|
)
|
|
|
|
|
elif _exc_is_rate_limit:
|
|
|
|
|
_exc_label, _exc_type, _exc_hint = (
|
|
|
|
|
'Rate limit reached', 'rate_limit',
|
|
|
|
|
'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
|
|
|
|
|
)
|
|
|
|
|
elif _exc_is_auth:
|
|
|
|
|
_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.',
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
_exc_label, _exc_type, _exc_hint = 'Error', 'error', ''
|
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:
|
|
|
|
|
s.active_stream_id = None
|
|
|
|
|
s.pending_user_message = None
|
|
|
|
|
s.pending_attachments = []
|
|
|
|
|
s.pending_started_at = None
|
|
|
|
|
s.messages.append({
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
'content': f'**{_exc_label}:** {err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''),
|
|
|
|
|
'timestamp': int(time.time()),
|
|
|
|
|
'_error': True,
|
|
|
|
|
})
|
|
|
|
|
try:
|
|
|
|
|
s.save()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-04-20 22:48:19 +00:00
|
|
|
_apperror_payload: dict = {'message': err_str, 'type': _exc_type}
|
|
|
|
|
if _exc_hint:
|
|
|
|
|
_apperror_payload['hint'] = _exc_hint
|
|
|
|
|
put('apperror', _apperror_payload)
|
2026-03-31 03:40:19 +00:00
|
|
|
finally:
|
2026-04-21 19:07:44 +00:00
|
|
|
# Stop periodic checkpoint thread if it was started (Issue #765)
|
|
|
|
|
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-03-31 03:40:19 +00:00
|
|
|
_clear_thread_env() # TD1: always clear thread-local context
|
|
|
|
|
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-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.
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cancel_stream(stream_id: str) -> bool:
|
2026-04-20 23:54:40 +00:00
|
|
|
"""Signal an in-flight stream to cancel. Returns True if the stream existed.
|
|
|
|
|
|
|
|
|
|
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-03-31 03:40:19 +00:00
|
|
|
with STREAMS_LOCK:
|
|
|
|
|
if stream_id not in STREAMS:
|
|
|
|
|
return False
|
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
|
|
|
|
|
|
|
|
# Set WebUI layer cancel flag
|
2026-03-31 03:40:19 +00:00
|
|
|
flag = CANCEL_FLAGS.get(stream_id)
|
|
|
|
|
if flag:
|
|
|
|
|
flag.set()
|
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
|
|
|
|
|
|
|
|
# Interrupt the AIAgent instance to stop tool execution
|
|
|
|
|
agent = AGENT_INSTANCES.get(stream_id)
|
|
|
|
|
if agent:
|
|
|
|
|
try:
|
|
|
|
|
agent.interrupt("Cancelled by user")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# Log but don't block the cancel flow
|
|
|
|
|
import logging
|
|
|
|
|
logging.getLogger(__name__).debug(
|
|
|
|
|
f"Failed to interrupt agent for stream {stream_id}: {e}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# 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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-15 05:10:50 +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
|
|
|
|
|
|
|
|
|
|
if agent and getattr(agent, "session_id", None):
|
|
|
|
|
_clear_clarify_pending(agent.session_id)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Failed to clear clarify prompt during cancel")
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# Put a cancel sentinel into the queue so the SSE handler wakes up
|
|
|
|
|
q = STREAMS.get(stream_id)
|
|
|
|
|
if q:
|
|
|
|
|
try:
|
|
|
|
|
q.put_nowait(('cancel', {'message': 'Cancelled by user'}))
|
|
|
|
|
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 cancel event to queue")
|
2026-04-20 23:54:40 +00:00
|
|
|
|
|
|
|
|
# ── 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).
|
|
|
|
|
STREAMS.pop(stream_id, None)
|
|
|
|
|
CANCEL_FLAGS.pop(stream_id, None)
|
|
|
|
|
AGENT_INSTANCES.pop(stream_id, None)
|
2026-04-23 18:16:59 +00:00
|
|
|
# STREAM_PARTIAL_TEXT is intentionally NOT popped here — the agent thread may
|
|
|
|
|
# still be appending tokens. We capture the snapshot two lines below; the
|
|
|
|
|
# streaming finally block handles the cleanup when the thread exits.
|
2026-04-20 23:54:40 +00:00
|
|
|
|
2026-04-23 18:16:59 +00:00
|
|
|
# Capture partial text and session_id while holding STREAMS_LOCK (avoids a
|
|
|
|
|
# race where the agent thread deallocates the agent object or clears the
|
|
|
|
|
# partial text after we release).
|
2026-04-20 23:54:40 +00:00
|
|
|
# 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
|
2026-04-23 18:16:59 +00:00
|
|
|
_cancel_partial_text = STREAM_PARTIAL_TEXT.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)
|
|
|
|
|
_cs.active_stream_id = None
|
|
|
|
|
_cs.pending_user_message = None
|
|
|
|
|
_cs.pending_attachments = []
|
|
|
|
|
_cs.pending_started_at = None
|
|
|
|
|
# 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.
|
|
|
|
|
partial_text = _cancel_partial_text.strip() if _cancel_partial_text else ''
|
|
|
|
|
if partial_text:
|
|
|
|
|
import re as _re
|
|
|
|
|
# Strip thinking/reasoning markup from partial content before saving.
|
|
|
|
|
# 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 case — user stops mid-reasoning before the close tag appears).
|
|
|
|
|
_stripped = _re.sub(r'<think(?:ing)?\b[^>]*>.*',
|
|
|
|
|
'', _stripped,
|
|
|
|
|
flags=_re.DOTALL | _re.IGNORECASE).strip()
|
|
|
|
|
if _stripped:
|
|
|
|
|
# Mark _partial=True for session/UI identification only.
|
|
|
|
|
# Deliberately NOT _error=True — the partial content is real model
|
|
|
|
|
# output and should be visible in conversation history so the model
|
|
|
|
|
# can continue from it on the next turn (#893).
|
|
|
|
|
_cs.messages.append({
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
'content': _stripped,
|
|
|
|
|
'_partial': True,
|
|
|
|
|
'timestamp': int(time.time()),
|
|
|
|
|
})
|
|
|
|
|
# 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).
|
|
|
|
|
_cs.messages.append({
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
'content': '*Task cancelled.*',
|
|
|
|
|
'_error': True,
|
|
|
|
|
'timestamp': int(time.time()),
|
|
|
|
|
})
|
|
|
|
|
_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-03-31 03:40:19 +00:00
|
|
|
return True
|