fix(sessions): add sessions.show_subagents to list delegate runs
This commit is contained in:
@@ -2256,6 +2256,9 @@ DEFAULT_CONFIG = {
|
||||
"auto_archive": False,
|
||||
# Idle days before auto-archive hides a session (only when auto_archive is true).
|
||||
"auto_archive_days": 3,
|
||||
# List delegate_task subagent runs in session lists (desktop sidebar, dashboard, session.list),
|
||||
# nested under their parent. Off by default: they are machinery, not conversations.
|
||||
"show_subagents": False,
|
||||
# VACUUM after a prune that deleted rows (SQLite never reclaims disk on DELETE). VACUUM
|
||||
# blocks writes (~seconds per 100MB), so it runs only at startup, only when ≥1 session was
|
||||
# deleted AND freelist/page_count > 25%.
|
||||
|
||||
@@ -3,10 +3,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
_LIST_WORDS = {"list", "ls", "browse"}
|
||||
_SEARCH_WORDS = {"search", "find"}
|
||||
SUBAGENT_SOURCE = "subagent"
|
||||
|
||||
|
||||
def show_subagent_sessions(hermes_home: str | Path) -> bool:
|
||||
"""``sessions.show_subagents`` from *hermes_home*'s own config.yaml. One ``serve`` lists many
|
||||
profiles' stores, so the store's home decides, never the process ``HERMES_HOME``. False when
|
||||
the config cannot be read (the listing keeps its default shape)."""
|
||||
from hermes_cli.config import load_config_readonly
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
token = set_hermes_home_override(str(hermes_home))
|
||||
try:
|
||||
sessions_cfg = load_config_readonly().get("sessions") or {}
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return isinstance(sessions_cfg, dict) and is_truthy_value(sessions_cfg.get("show_subagents"))
|
||||
|
||||
|
||||
def subagent_listing_scope(
|
||||
hermes_home: str | Path, *, source: str | None = None, sources: list[str] | None = None,
|
||||
exclude_sources: list[str] | None = None,
|
||||
) -> tuple[bool, list[str] | None]:
|
||||
"""``(include_subagents, exclude_sources)`` for one human-facing session list (#97202).
|
||||
|
||||
With ``sessions.show_subagents`` on, delegate runs join a list that is not scoped to named
|
||||
sources and either has no exclusions or excludes the ``subagent`` source itself (the desktop
|
||||
recents shape); that exclusion is dropped so the runs actually land. A slice that excludes
|
||||
other sources without ``subagent`` (the per-platform messaging lists) keeps its shape.
|
||||
"""
|
||||
excluded = list(exclude_sources or [])
|
||||
if source or sources or (excluded and SUBAGENT_SOURCE not in excluded):
|
||||
return False, exclude_sources
|
||||
if not show_subagent_sessions(hermes_home):
|
||||
return False, exclude_sources
|
||||
return True, [s for s in excluded if s != SUBAGENT_SOURCE] or None
|
||||
|
||||
|
||||
def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | None]:
|
||||
|
||||
@@ -27,6 +27,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from hermes_cli.session_listing import subagent_listing_scope
|
||||
from hermes_cli.web_deps import late
|
||||
from hermes_cli.config import get_process_hermes_home
|
||||
from hermes_cli.profiles import ProfileIdentitySettlementPending
|
||||
@@ -483,12 +484,16 @@ def get_profiles_sessions(
|
||||
errors: List[Dict[str, str]] = []
|
||||
now = time.time()
|
||||
for name, home in targets:
|
||||
def _read(db, name=name):
|
||||
def _read(db, name=name, home=home):
|
||||
include_subagents, exclude = subagent_listing_scope(
|
||||
home, source=filters["source"], sources=filters["sources"],
|
||||
exclude_sources=filters["exclude_sources"])
|
||||
scoped = {**filters, "exclude_sources": exclude, "include_subagents": include_subagents}
|
||||
rows = db.list_sessions_rich(
|
||||
limit=per_profile, offset=0, order_by_last_active=order == "recent",
|
||||
# Same SQL-level blob skip as /api/sessions.
|
||||
compact_rows=not full, include_pinned=True, **filters)
|
||||
totals[name] = db.session_count(exclude_children=True, **filters)
|
||||
compact_rows=not full, include_pinned=True, **scoped)
|
||||
totals[name] = db.session_count(exclude_children=True, **scoped)
|
||||
merged.extend(_tag_rows(rows, name, now))
|
||||
_read_profile_db(name, home, errors, _read)
|
||||
|
||||
@@ -534,19 +539,22 @@ def get_profiles_sessions_sidebar(
|
||||
errors: List[Dict[str, str]] = []
|
||||
now = time.time()
|
||||
|
||||
def _slice(db, key):
|
||||
def _slice(db, key, recents_subagents=(False, None)):
|
||||
source, exclude = slice_scope[key]
|
||||
# Only recents takes subagent runs (sessions.show_subagents); cron/messaging keep shape.
|
||||
include_subagents, exclude = recents_subagents if key == "recents" else (False, exclude)
|
||||
# include_pinned: a pinned conversation must reach the sidebar even when it has aged
|
||||
# past the window, or its Pinned row renders empty.
|
||||
return db.list_sessions_rich(
|
||||
source=source, exclude_sources=exclude or None, limit=cap[key], offset=0,
|
||||
min_message_count=1, include_archived=False, archived_only=False,
|
||||
order_by_last_active=True, compact_rows=True, include_pinned=True)
|
||||
order_by_last_active=True, compact_rows=True, include_pinned=True,
|
||||
include_subagents=include_subagents)
|
||||
|
||||
def _build_slices(db, cache_key):
|
||||
def _build_slices(db, cache_key, recents_subagents):
|
||||
# ``usage`` is aggregated in SQL rather than over the recents window: the window is a
|
||||
# page, and a total that shrank when you scrolled would be worse than no total at all.
|
||||
slices = {"recents": _slice(db, "recents"), "usage": db.usage_totals(),
|
||||
slices = {"recents": _slice(db, "recents", recents_subagents), "usage": db.usage_totals(),
|
||||
"cron": _slice(db, "cron"), "messaging": _slice(db, "messaging")}
|
||||
_sidebar_profile_cache_put(cache_key, slices)
|
||||
return slices
|
||||
@@ -560,13 +568,15 @@ def get_profiles_sessions_sidebar(
|
||||
db_path = _profile_state_db(home)
|
||||
if not db_path.exists():
|
||||
continue
|
||||
recents_subagents = subagent_listing_scope(home, exclude_sources=recents_exclude_list or None)
|
||||
profile_cache_key = (str(db_path), _sidebar_db_fingerprint(db_path), cap["recents"],
|
||||
tuple(recents_exclude_list), cap["cron"], cap["messaging"],
|
||||
tuple(messaging_exclude_list))
|
||||
tuple(messaging_exclude_list), recents_subagents[0])
|
||||
slices = _sidebar_profile_cache_get(profile_cache_key)
|
||||
if slices is None:
|
||||
slices = _read_profile_db(name, home, errors,
|
||||
lambda db: _build_slices(db, profile_cache_key))
|
||||
slices = _read_profile_db(
|
||||
name, home, errors,
|
||||
lambda db: _build_slices(db, profile_cache_key, recents_subagents))
|
||||
if slices is None:
|
||||
continue
|
||||
# Heal already gave up and this read found no rows. That is not "no
|
||||
|
||||
@@ -11,12 +11,14 @@ import json
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from hermes_cli.session_listing import subagent_listing_scope
|
||||
from hermes_cli.web_deps import late
|
||||
from hermes_cli.web_server_gateway import _strip_session_list_rows
|
||||
from hermes_cli.web_server_sessions import _maybe_auto_archive_for_profile, _session_latest_descendant
|
||||
@@ -201,12 +203,14 @@ def get_sessions(
|
||||
# Source scoping: the desktop splits recents (exclude=cron) from
|
||||
# the cron-jobs section (source=cron) into two independent lists.
|
||||
source_list = _csv(sources)
|
||||
exclude_list = _csv(exclude_sources)
|
||||
include_subagents, exclude_list = subagent_listing_scope(
|
||||
Path(db.db_path).parent, source=source or None, sources=source_list or None,
|
||||
exclude_sources=_csv(exclude_sources) or None)
|
||||
scope = dict(
|
||||
source=source or None, sources=source_list or None,
|
||||
exclude_sources=exclude_list or None, cwd_prefix=(cwd_prefix or None),
|
||||
min_message_count=min_message_count, include_archived=include_archived,
|
||||
archived_only=archived_only)
|
||||
archived_only=archived_only, include_subagents=include_subagents)
|
||||
sessions = db.list_sessions_rich(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
|
||||
@@ -95,13 +95,17 @@ def _session_filter_where(
|
||||
*, exclude_children: bool = False, source: str = None, sources: List[str] = None,
|
||||
session_key: str = None, exclude_sources: List[str] = None, cwd_prefix: str = None,
|
||||
min_message_count: int = 0, archived_only: bool = False, include_archived: bool = False,
|
||||
include_subagents: bool = False,
|
||||
) -> Tuple[List[str], List[Any]]:
|
||||
"""Shared ``sessions s`` WHERE builder so counts line up with listed rows. ``exclude_children``
|
||||
hides sub-agent runs and compression continuations but keeps branch/reset children
|
||||
(``_LISTABLE_CHILD_SQL``). Clause order is part of the SQL text contract."""
|
||||
(``_LISTABLE_CHILD_SQL``); ``include_subagents`` re-admits the sub-agent runs only
|
||||
(``sessions.show_subagents``). Clause order is part of the SQL text contract."""
|
||||
where: List[str] = []
|
||||
params: List[Any] = []
|
||||
if exclude_children:
|
||||
if exclude_children and include_subagents:
|
||||
where.append(f"({_LISTABLE_CHILD_SQL} OR {_delegate_from_json('s.model_config')} IS NOT NULL)")
|
||||
elif exclude_children:
|
||||
where += [_LISTABLE_CHILD_SQL, f"{_delegate_from_json('s.model_config')} IS NULL"]
|
||||
# Show roots and user-visible branch/reset sessions, while still hiding sub-agent runs and compression
|
||||
# continuations. All four carry parent_session_id, so the shared predicate classifies the edge from
|
||||
@@ -1257,6 +1261,7 @@ class SessionSessionsMixin:
|
||||
order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False,
|
||||
id_query: str = None, search_query: str = None, compact_rows: bool = False,
|
||||
include_pinned: bool = False, session_key: str = None, include_hidden: bool = False,
|
||||
include_subagents: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview and ``last_active`` in one query. ``order_by_last_active`` sorts
|
||||
by the chain TIP via a recursive CTE (the only path honouring ``id_query`` / ``search_query``);
|
||||
@@ -1267,7 +1272,7 @@ class SessionSessionsMixin:
|
||||
where_clauses, params = _session_filter_where(
|
||||
exclude_children=not include_children, source=source, sources=sources, session_key=session_key,
|
||||
exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
|
||||
archived_only=archived_only, include_archived=include_archived,
|
||||
archived_only=archived_only, include_archived=include_archived, include_subagents=include_subagents,
|
||||
)
|
||||
# The archived-only view is the recovery surface for rows that dropped out of every
|
||||
# default list: a session that is archived AND hidden (Bot Mode marks its sessions
|
||||
@@ -1345,6 +1350,7 @@ class SessionSessionsMixin:
|
||||
exclude_children=not include_children, source=source, sources=sources,
|
||||
session_key=session_key, exclude_sources=exclude_sources, cwd_prefix=cwd_prefix,
|
||||
min_message_count=min_message_count, archived_only=False, include_archived=True,
|
||||
include_subagents=include_subagents,
|
||||
)
|
||||
if not include_hidden and not archived_only:
|
||||
pinned_clauses.append("s.hidden = 0")
|
||||
@@ -1467,13 +1473,13 @@ class SessionSessionsMixin:
|
||||
def session_count(
|
||||
self, source: str = None, sources: List[str] = None, cwd_prefix: str = None,
|
||||
min_message_count: int = 0, include_archived: bool = False, archived_only: bool = False,
|
||||
exclude_children: bool = False, exclude_sources: List[str] = None,
|
||||
exclude_children: bool = False, exclude_sources: List[str] = None, include_subagents: bool = False,
|
||||
) -> int:
|
||||
"""Count sessions with list_sessions_rich's filters so a paired "load more" total matches."""
|
||||
where_clauses, params = _session_filter_where(
|
||||
exclude_children=exclude_children, source=source, sources=sources,
|
||||
exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
|
||||
archived_only=archived_only, include_archived=include_archived,
|
||||
archived_only=archived_only, include_archived=include_archived, include_subagents=include_subagents,
|
||||
)
|
||||
return self._read_one(f"SELECT COUNT(*) FROM sessions s{_where_sql(where_clauses, ' ')}", params)[0]
|
||||
|
||||
|
||||
@@ -282,3 +282,35 @@ class TestSidebarTruncation:
|
||||
_seed_session(home, f"s-{index}", source="desktop", pinned=index == 5)
|
||||
# Six on disk, two pins among the newest four: a full window, more below it.
|
||||
assert window() == (4, {"default": True})
|
||||
|
||||
|
||||
class TestSidebarShowSubagents:
|
||||
"""``sessions.show_subagents`` is read from each profile's OWN config and only widens recents (#97202)."""
|
||||
|
||||
@staticmethod
|
||||
def _seed_subagent(home, parent_id, child_id):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
_seed_session(home, parent_id, source="desktop")
|
||||
db = SessionDB(db_path=home / "state.db")
|
||||
try:
|
||||
db.create_session(child_id, source="subagent", parent_session_id=parent_id,
|
||||
model_config={"_delegate_from": parent_id})
|
||||
db.append_message(session_id=child_id, role="user", content="audit billing")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_recents_list_subagent_runs_only_for_the_profile_that_opted_in(self, client, profiles_on_disk):
|
||||
(profiles_on_disk["worker"] / "config.yaml").write_text("sessions:\n show_subagents: true\n")
|
||||
self._seed_subagent(profiles_on_disk["default"], "default-parent", "default-sub")
|
||||
self._seed_subagent(profiles_on_disk["worker"], "worker-parent", "worker-sub")
|
||||
|
||||
payload = client.get(
|
||||
"/api/profiles/sessions/sidebar",
|
||||
params={"recents_profile": "all", "recents_exclude": "cron,subagent", "messaging_exclude": "cli,cron,desktop"},
|
||||
).json()
|
||||
|
||||
assert payload["errors"] == []
|
||||
assert _slice_ids(payload, "recents") == {"default-parent", "worker-parent", "worker-sub"}
|
||||
# The messaging slice keeps its shape: a subagent run is not a platform thread.
|
||||
assert _slice_ids(payload, "messaging") == set()
|
||||
|
||||
60
tests/hermes_state/test_show_subagents_listing.py
Normal file
60
tests/hermes_state/test_show_subagents_listing.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""``sessions.show_subagents`` re-admits delegate runs to human-facing session lists (#97202)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.session_listing import subagent_listing_scope
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
store = SessionDB(db_path=tmp_path / "state.db")
|
||||
store.create_session(session_id="parent", source="desktop", model="m")
|
||||
store.create_session(session_id="sub", source="desktop", model="m", parent_session_id="parent",
|
||||
model_config={"_delegate_from": "parent"})
|
||||
# A compression continuation is not a subagent run and must stay hidden either way.
|
||||
store.create_session(session_id="parent-cont", source="desktop", model="m", parent_session_id="parent")
|
||||
store.end_session("parent", "compression")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def _ids(rows):
|
||||
return {row["id"] for row in rows}
|
||||
|
||||
|
||||
def test_subagent_runs_stay_hidden_by_default(db):
|
||||
assert "sub" not in _ids(db.list_sessions_rich(limit=50, project_compression_tips=False))
|
||||
assert db.session_count(exclude_children=True) == 1
|
||||
|
||||
|
||||
def test_include_subagents_lists_delegate_runs_with_a_matching_count(db):
|
||||
rows = db.list_sessions_rich(limit=50, include_subagents=True, project_compression_tips=False)
|
||||
assert _ids(rows) == {"parent", "sub"}
|
||||
assert next(r for r in rows if r["id"] == "sub")["parent_session_id"] == "parent"
|
||||
assert db.session_count(exclude_children=True, include_subagents=True) == 2
|
||||
|
||||
|
||||
def _home(tmp_path, show):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
(home / "config.yaml").write_text(f"sessions:\n show_subagents: {str(show).lower()}\n")
|
||||
return home
|
||||
|
||||
|
||||
def test_listing_scope_follows_the_store_home_config(tmp_path):
|
||||
assert subagent_listing_scope(_home(tmp_path, False)) == (False, None)
|
||||
assert subagent_listing_scope(_home(tmp_path, True)) == (True, None)
|
||||
|
||||
|
||||
def test_listing_scope_drops_the_subagent_exclusion_from_the_recents_shape(tmp_path):
|
||||
home = _home(tmp_path, True)
|
||||
assert subagent_listing_scope(home, exclude_sources=["cron", "subagent"]) == (True, ["cron"])
|
||||
assert subagent_listing_scope(home, exclude_sources=["subagent"]) == (True, None)
|
||||
|
||||
|
||||
def test_listing_scope_leaves_source_scoped_and_messaging_slices_alone(tmp_path):
|
||||
home = _home(tmp_path, True)
|
||||
assert subagent_listing_scope(home, source="cron") == (False, None)
|
||||
assert subagent_listing_scope(home, sources=["telegram"]) == (False, None)
|
||||
assert subagent_listing_scope(home, exclude_sources=["cron", "cli", "desktop"]) == (False, ["cron", "cli", "desktop"])
|
||||
@@ -518,7 +518,13 @@ def _(rid, params: dict, db) -> dict:
|
||||
limit = int(params.get("limit", 200) or 200)
|
||||
# Over-fetch: per-source filtering + tip merging must not leave us short. ``include_hidden`` is for
|
||||
# surfaces that OWN hidden sessions (Bots pane, pickers).
|
||||
rows = _listing_rows(db, max(limit * 2, 200), include_hidden=_flag(params, "include_hidden"))[:limit]
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.session_listing import show_subagent_sessions
|
||||
|
||||
# ``sessions.show_subagents`` (the store's own profile config) re-admits delegate runs (#97202).
|
||||
rows = _listing_rows(db, max(limit * 2, 200), include_hidden=_flag(params, "include_hidden"),
|
||||
include_subagents=show_subagent_sessions(Path(db.db_path).parent))[:limit]
|
||||
return _ok(rid, {"sessions": [_session_row_summary(s) for s in rows]})
|
||||
except Exception as e:
|
||||
return _err(rid, 5006, str(e))
|
||||
|
||||
Reference in New Issue
Block a user