fix(sessions): add sessions.show_subagents to list delegate runs

This commit is contained in:
Hermes Agent
2026-09-24 23:48:13 -05:00
committed by brooklyn!
parent f41c6517ea
commit 2e43498413
8 changed files with 179 additions and 18 deletions

View File

@@ -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%.

View File

@@ -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]:

View File

@@ -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

View File

@@ -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,

View File

@@ -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]

View File

@@ -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()

View 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"])

View File

@@ -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))