Merge pull request #6103 from nesquena/stage/6056-delegtitles
Release exp: delegated subagent sidebar titles (#6056) + sidebar title-redaction hardening
This commit is contained in:
5 files changed
+350
-4
No files matched your search
@@ -69,6 +69,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Delegated subagent sessions in the sidebar now show a meaningful title instead of "Subagent Session".** Generic delegated child sessions previously all displayed the placeholder "Subagent Session" in the conversations list, making them indistinguishable. Their sidebar title is now derived (view-only) from the child's first user message during the existing batched sidebar-metadata pass — without mutating the stored session title, so the read-only boundary is preserved. Only generic "Subagent Session" placeholders are affected; custom titles are left untouched. Thanks @rodboev. (#6056, #6033)
|
||||
|
||||
- **Manual "Check for updates" now works even when automatic update checks are turned off.** Previously, disabling automatic update checks in Preferences → System also disabled the manual "check now" button, leaving no way to update from the WebUI without re-enabling the (noisy) automatic banner. The explicit manual force-check now bypasses the disabled auto-check toggle; the automatic path still honors the setting. Thanks @webtecnica. (#6088, #6082)
|
||||
|
||||
- **No more spurious "No response from provider" after a tool-limit turn that did answer.** When a turn hit the per-turn tool-iteration limit (`max_iterations_reached`) but the model had already produced a valid final answer, the settlement classifier still appended a "No response from provider" error card after the good reply — a regression of the earlier false-`no_response` fix, because the suppression path admitted only `_soft_partial_terminal_failure` and deliberately excluded `_tool_limit_reached`. The suppression now also covers `_tool_limit_reached`, but *only* when the transcript already holds a final answer; a tool-limit turn with no final answer still fails honestly, and explicit errors / cancellations / interruptions stay outside the suppression. Thanks @coyaSONG. (#6081, #6079)
|
||||
|
||||
@@ -5256,13 +5256,16 @@ def _read_state_db_sidebar_overrides(
|
||||
has_messages_table = cur.fetchone() is not None
|
||||
messages_has_session_id = False
|
||||
messages_has_timestamp = False
|
||||
messages_has_title_fields = False
|
||||
if has_messages_table:
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
message_cols = {str(row[1]) for row in cur.fetchall()}
|
||||
messages_has_session_id = 'session_id' in message_cols
|
||||
messages_has_timestamp = 'timestamp' in message_cols
|
||||
messages_has_title_fields = {'session_id', 'role', 'content', 'timestamp'}.issubset(message_cols)
|
||||
|
||||
overrides: dict[str, dict] = {}
|
||||
delegated_title_ids: set[str] = set()
|
||||
ids = list(wanted)
|
||||
chunk_size = 500
|
||||
for i in range(0, len(ids), chunk_size):
|
||||
@@ -5283,6 +5286,12 @@ def _read_state_db_sidebar_overrides(
|
||||
if state_title:
|
||||
entry['_state_db_title'] = state_title
|
||||
state_source = str(row['source'] or '').strip().lower()
|
||||
if (
|
||||
state_source == 'subagent'
|
||||
and sid in count_wanted
|
||||
and ' '.join(state_title.split()) == 'Subagent Session'
|
||||
):
|
||||
delegated_title_ids.add(sid)
|
||||
if state_source:
|
||||
entry['_state_db_source'] = state_source
|
||||
source_meta = normalize_agent_session_source(state_source)
|
||||
@@ -5327,6 +5336,29 @@ def _read_state_db_sidebar_overrides(
|
||||
entry['_state_db_last_message_at'] = float(row['last_message_at'] or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if messages_has_title_fields and delegated_title_ids:
|
||||
seen_user_messages: set[str] = set()
|
||||
delegated_title_ids = list(delegated_title_ids)
|
||||
for i in range(0, len(delegated_title_ids), chunk_size):
|
||||
chunk = delegated_title_ids[i:i + chunk_size]
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT session_id, role, content, timestamp
|
||||
FROM messages
|
||||
WHERE session_id IN ({placeholders}) AND role = 'user'
|
||||
ORDER BY session_id, timestamp ASC
|
||||
""",
|
||||
chunk,
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
sid = str(row['session_id'])
|
||||
if sid in seen_user_messages:
|
||||
continue
|
||||
display_title = title_from([dict(row)], fallback='')
|
||||
if display_title:
|
||||
seen_user_messages.add(sid)
|
||||
overrides.setdefault(sid, {})['_state_db_display_title'] = display_title
|
||||
return overrides
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -5381,6 +5413,7 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di
|
||||
state_db_source_label = entry.pop('_state_db_source_label', None)
|
||||
state_db_message_count = entry.pop('_state_db_message_count', None)
|
||||
state_db_last_message_at = entry.pop('_state_db_last_message_at', None)
|
||||
state_db_display_title = entry.pop('_state_db_display_title', None)
|
||||
if state_db_source == 'webui':
|
||||
session['source_tag'] = state_db_source_tag
|
||||
session['raw_source'] = state_db_raw_source
|
||||
@@ -5440,6 +5473,12 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di
|
||||
):
|
||||
session['_state_db_title'] = state_db_title
|
||||
session['display_title'] = state_db_title
|
||||
if (
|
||||
state_db_display_title
|
||||
and state_db_source == 'subagent'
|
||||
and ' '.join(str(title or '').split()) == 'Subagent Session'
|
||||
):
|
||||
session['display_title'] = state_db_display_title
|
||||
|
||||
|
||||
def _enrich_sidebar_lineage_metadata(sessions: list[dict]) -> None:
|
||||
@@ -5711,6 +5750,8 @@ def title_from(messages, fallback: str='Untitled'):
|
||||
for m in messages:
|
||||
if m.get('role') == 'user':
|
||||
c = m.get('content', '')
|
||||
if c is None:
|
||||
continue
|
||||
if isinstance(c, list):
|
||||
c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text')
|
||||
text = _strip_attached_files_marker(str(c))
|
||||
|
||||
+35
-4
@@ -9632,10 +9632,30 @@ def _sidebar_session_response_item(session: dict, *, redact_enabled: bool | None
|
||||
}
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"], _enabled=redact_enabled)
|
||||
_redact_sidebar_title_fields(item, redact_enabled)
|
||||
item["attention"] = _session_attention_summary(str(item.get("session_id") or ""))
|
||||
return item
|
||||
|
||||
|
||||
def _redact_sidebar_title_fields(item: dict, redact_enabled: bool | None = None) -> None:
|
||||
"""Redact every user-content-derived title field on a sidebar/search row in place.
|
||||
|
||||
`title` is redacted by the callers directly (they special-case it), but
|
||||
`display_title`, `_state_db_title`, and `parent_title` can ALSO carry raw
|
||||
user-message-derived text — e.g. #6056 derives a delegated subagent's
|
||||
`display_title` from its first user message, and `parent_title` copies a
|
||||
parent session's (possibly derived) title. Without this a credential-shaped
|
||||
value in a delegated goal would surface in the sidebar / search results even
|
||||
with `api_redact_enabled=True`. Shared by `_sidebar_session_response_item`
|
||||
(`/api/sessions`) and every `/api/sessions/search` response branch so the two
|
||||
endpoints can never drift on which fields get redacted.
|
||||
"""
|
||||
for field in ("display_title", "_state_db_title", "parent_title"):
|
||||
value = item.get(field)
|
||||
if isinstance(value, str):
|
||||
item[field] = _redact_text(value, _enabled=redact_enabled)
|
||||
|
||||
|
||||
# ── Login page locale strings ─────────────────────────────────────────────────
|
||||
# Add entries here to support more languages on the login page.
|
||||
# The key must match the 'language' setting value (from static/i18n.js LOCALES).
|
||||
@@ -16567,12 +16587,21 @@ def _handle_sessions_search(handler, parsed):
|
||||
depth = max(0, int(qs.get("depth", ["5"])[0]))
|
||||
except (ValueError, TypeError):
|
||||
depth = 5
|
||||
# Read the redaction setting ONCE for the whole response (mirrors the
|
||||
# /api/sessions read-once optimization, #4662) and thread it through every
|
||||
# branch + the shared title-field redactor so search rows redact the same
|
||||
# fields as the sidebar list.
|
||||
try:
|
||||
_search_redact_enabled = bool(load_settings().get("api_redact_enabled", True))
|
||||
except Exception:
|
||||
_search_redact_enabled = True # fail safe: redact when settings unreadable
|
||||
if not q:
|
||||
safe_sessions = []
|
||||
for s in sessions:
|
||||
item = dict(s)
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"])
|
||||
item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled)
|
||||
_redact_sidebar_title_fields(item, _search_redact_enabled)
|
||||
safe_sessions.append(item)
|
||||
return j(handler, {
|
||||
"sessions": safe_sessions,
|
||||
@@ -16585,7 +16614,8 @@ def _handle_sessions_search(handler, parsed):
|
||||
if title_match:
|
||||
item = dict(s, match_type="title")
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"])
|
||||
item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled)
|
||||
_redact_sidebar_title_fields(item, _search_redact_enabled)
|
||||
results.append(item)
|
||||
continue
|
||||
if content_search:
|
||||
@@ -16598,9 +16628,10 @@ def _handle_sessions_search(handler, parsed):
|
||||
item = dict(s, match_type="content")
|
||||
preview = _session_search_preview(c, q)
|
||||
if preview:
|
||||
item["match_preview"] = _redact_text(preview)
|
||||
item["match_preview"] = _redact_text(preview, _enabled=_search_redact_enabled)
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"])
|
||||
item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled)
|
||||
_redact_sidebar_title_fields(item, _search_redact_enabled)
|
||||
results.append(item)
|
||||
break
|
||||
except (KeyError, Exception):
|
||||
|
||||
@@ -65,3 +65,66 @@ def test_sidebar_payload_no_redaction_when_disabled(monkeypatch):
|
||||
resp = routes._session_list_payload_to_response(payload)
|
||||
assert resp["sessions"][0]["title"] == "plain title"
|
||||
assert calls["n"] <= 1, f"settings read {calls['n']}x with redaction disabled; expected <=1"
|
||||
|
||||
|
||||
def test_sidebar_payload_redacts_display_and_parent_titles(monkeypatch):
|
||||
"""#6056 derives a delegated subagent's display_title from raw user-message
|
||||
content, so display_title / _state_db_title / parent_title must go through the
|
||||
SAME redaction as title — a credential in a delegated goal must not leak to
|
||||
the sidebar even though only `title` was redacted before."""
|
||||
monkeypatch.setattr("api.config.load_settings", lambda: {"api_redact_enabled": True})
|
||||
monkeypatch.setattr("api.helpers.load_settings", lambda: {"api_redact_enabled": True}, raising=False)
|
||||
monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows)
|
||||
|
||||
secret = "sk-" + ("A" * 44)
|
||||
payload = {"sessions": [{
|
||||
"session_id": "s1",
|
||||
"title": "clean",
|
||||
"display_title": f"debug {secret}",
|
||||
"_state_db_title": f"goal {secret}",
|
||||
"parent_title": f"parent {secret}",
|
||||
}], "cli_count": 0}
|
||||
resp = routes._session_list_payload_to_response(payload)
|
||||
row = resp["sessions"][0]
|
||||
for field in ("display_title", "_state_db_title", "parent_title"):
|
||||
assert secret not in str(row.get(field, "")), (
|
||||
f"credential leaked into sidebar {field}: {row.get(field)!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_redact_sidebar_title_fields_helper():
|
||||
"""The shared helper redacts every user-content-derived title field, honors the
|
||||
enabled flag, and never raises on missing/non-str fields."""
|
||||
secret = "sk-" + ("B" * 44)
|
||||
item = {
|
||||
"title": "kept-by-caller",
|
||||
"display_title": f"a {secret}",
|
||||
"_state_db_title": f"b {secret}",
|
||||
"parent_title": f"c {secret}",
|
||||
"session_id": "s1",
|
||||
}
|
||||
routes._redact_sidebar_title_fields(item, True)
|
||||
for field in ("display_title", "_state_db_title", "parent_title"):
|
||||
assert secret not in item[field], f"{field} not redacted"
|
||||
# Disabled → pass through unchanged.
|
||||
item2 = {"display_title": f"a {secret}"}
|
||||
routes._redact_sidebar_title_fields(item2, False)
|
||||
assert item2["display_title"] == f"a {secret}"
|
||||
# Missing / non-str fields must not raise.
|
||||
routes._redact_sidebar_title_fields({"display_title": None, "parent_title": 42}, True)
|
||||
|
||||
|
||||
def test_sessions_search_branches_redact_derived_titles():
|
||||
"""Every /api/sessions/search response branch must call the shared title-field
|
||||
redactor so search rows can't leak a derived display_title the sidebar hides.
|
||||
Source-guard: the #6056 class-fix is easy to regress by adding a 4th branch."""
|
||||
import inspect
|
||||
src = inspect.getsource(routes._handle_sessions_search)
|
||||
# 3 response branches (empty-query list, title-match, content-match) each build
|
||||
# an `item` and must pass it through _redact_sidebar_title_fields.
|
||||
assert src.count("_redact_sidebar_title_fields(item") >= 3, (
|
||||
"a /api/sessions/search branch builds a row without redacting the derived "
|
||||
"title fields (display_title/_state_db_title/parent_title)"
|
||||
)
|
||||
# And it must read the setting once, not per-_redact_text call.
|
||||
assert "_search_redact_enabled" in src
|
||||
@@ -58,6 +58,27 @@ def _ensure_state_db(path):
|
||||
return conn
|
||||
|
||||
|
||||
def _ensure_messages_table(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE messages (
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
timestamp REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _insert_state_message(conn, sid, *, role, content, timestamp):
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||
(sid, role, content, timestamp),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None):
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -391,6 +412,194 @@ def test_generic_webui_title_gets_read_only_state_db_display_title(_isolate):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_generic_subagent_title_gets_goal_display_title(_isolate):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
t0 = time.time() - 100
|
||||
try:
|
||||
_save_webui_session("lineage_api_subagent_goal", title="Subagent Session", updated_at=t0)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_goal",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=t0,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_goal",
|
||||
role="user",
|
||||
content="Find the root cause of the failing sidebar test",
|
||||
timestamp=t0 + 1,
|
||||
)
|
||||
|
||||
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_goal"]
|
||||
|
||||
assert row["title"] == "Subagent Session"
|
||||
assert row["display_title"] == "Find the root cause of the failing sidebar test"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_custom_subagent_title_stays_authoritative(_isolate):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
t0 = time.time() - 100
|
||||
try:
|
||||
_save_webui_session("lineage_api_subagent_custom", title="Investigate auth", updated_at=t0)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_custom",
|
||||
title="Investigate auth",
|
||||
source="subagent",
|
||||
started_at=t0,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_custom",
|
||||
role="user",
|
||||
content="A different goal",
|
||||
timestamp=t0 + 1,
|
||||
)
|
||||
|
||||
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_custom"]
|
||||
|
||||
assert row["title"] == "Investigate auth"
|
||||
assert "display_title" not in row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_generic_subagent_title_falls_back_without_first_user_message(_isolate):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
t0 = time.time() - 100
|
||||
try:
|
||||
_save_webui_session("lineage_api_subagent_empty", title="Subagent Session", updated_at=t0)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_empty",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=t0,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_empty",
|
||||
role="assistant",
|
||||
content="Only assistant output",
|
||||
timestamp=t0 + 1,
|
||||
)
|
||||
|
||||
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_empty"]
|
||||
|
||||
assert row["title"] == "Subagent Session"
|
||||
assert "display_title" not in row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_generic_subagent_title_skips_null_first_user_message(_isolate):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
t0 = time.time() - 100
|
||||
try:
|
||||
_save_webui_session("lineage_api_subagent_null_first", title="Subagent Session", updated_at=t0)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_null_first",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=t0,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_null_first",
|
||||
role="user",
|
||||
content=None,
|
||||
timestamp=t0 + 1,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_null_first",
|
||||
role="user",
|
||||
content="Recover the next usable delegated title",
|
||||
timestamp=t0 + 2,
|
||||
)
|
||||
|
||||
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_null_first"]
|
||||
|
||||
assert row["title"] == "Subagent Session"
|
||||
assert row["display_title"] == "Recover the next usable delegated title"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_generic_subagent_title_respects_sidebar_override_cap(_isolate, monkeypatch):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
older = time.time() - 200
|
||||
newer = time.time() - 100
|
||||
try:
|
||||
monkeypatch.setenv("HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N", "1")
|
||||
_save_webui_session("lineage_api_subagent_old", title="Subagent Session", updated_at=older)
|
||||
_save_webui_session("lineage_api_subagent_new", title="Subagent Session", updated_at=newer)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_old",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=older,
|
||||
)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_new",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=newer,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_old",
|
||||
role="user",
|
||||
content="Older delegated title",
|
||||
timestamp=older + 1,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn,
|
||||
"lineage_api_subagent_new",
|
||||
role="user",
|
||||
content="Newest delegated title",
|
||||
timestamp=newer + 1,
|
||||
)
|
||||
|
||||
rows = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}
|
||||
|
||||
assert rows["lineage_api_subagent_new"]["display_title"] == "Newest delegated title"
|
||||
assert "display_title" not in rows["lineage_api_subagent_old"]
|
||||
finally:
|
||||
conn.close()
|
||||
def test_generic_subagent_title_falls_back_without_messages_table(_isolate):
|
||||
conn = _ensure_state_db(_isolate)
|
||||
t0 = time.time() - 100
|
||||
try:
|
||||
_save_webui_session("lineage_api_subagent_no_messages", title="Subagent Session", updated_at=t0)
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_api_subagent_no_messages",
|
||||
title="Subagent Session",
|
||||
source="subagent",
|
||||
started_at=t0,
|
||||
)
|
||||
|
||||
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_no_messages"]
|
||||
|
||||
assert row["title"] == "Subagent Session"
|
||||
assert "display_title" not in row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_state_db_display_title_does_not_override_custom_json_title(_isolate):
|
||||
"""Manual/custom JSON titles stay authoritative even when state.db differs."""
|
||||
conn = _ensure_state_db(_isolate)
|
||||
|
||||
Reference in new issue
Block a user