diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 098c7322be..f07d15ea4f 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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%. diff --git a/hermes_cli/session_listing.py b/hermes_cli/session_listing.py index 7c4806a7a0..a204f56e30 100644 --- a/hermes_cli/session_listing.py +++ b/hermes_cli/session_listing.py @@ -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]: diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 0f6434dee3..553530b50c 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -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 diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index 77919e3e7f..1842c7e0d4 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -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, diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 0b8b061d57..ef5701a1e2 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -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] diff --git a/tests/hermes_cli/test_profiles_sidebar_scope.py b/tests/hermes_cli/test_profiles_sidebar_scope.py index d2cd699e5b..2c059257bd 100644 --- a/tests/hermes_cli/test_profiles_sidebar_scope.py +++ b/tests/hermes_cli/test_profiles_sidebar_scope.py @@ -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() diff --git a/tests/hermes_state/test_show_subagents_listing.py b/tests/hermes_state/test_show_subagents_listing.py new file mode 100644 index 0000000000..19072341a8 --- /dev/null +++ b/tests/hermes_state/test_show_subagents_listing.py @@ -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"]) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 9007bd39dc..fcb9bc3403 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -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))