From 7409715947a76ecba6edf3b0ea3cde4512d80892 Mon Sep 17 00:00:00 2001 From: donrhmexe Date: Sat, 4 Apr 2026 23:03:46 +0200 Subject: [PATCH] fix: link subagent sessions to parent and hide from session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagent sessions spawned by delegate_task were created with parent_session_id=NULL and source=cli, making them indistinguishable from user sessions in hermes sessions list and /resume. Changes: - delegate_tool.py: pass parent_agent.session_id to child agent - run_agent.py: accept parent_session_id param, pass to create_session - hermes_state.py list_sessions_rich: filter parent_session_id IS NULL by default (opt-in include_children=True for callers that need them) - hermes_state.py delete_session: delete child sessions first (FK) - hermes_state.py prune_sessions: delete children before parents (FK) session_search already handles parent_session_id correctly — child sessions are filtered from recent list and resolved to parent root in full-text search results. Fixes #5122 --- hermes_state.py | 45 +++++++++++++++++++++++++++++++++++++----- run_agent.py | 3 +++ tools/delegate_tool.py | 1 + 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 54cec8437..6f6be056a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -787,6 +787,7 @@ class SessionDB: exclude_sources: List[str] = None, limit: int = 20, offset: int = 0, + include_children: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -795,10 +796,16 @@ class SessionDB: last_active (timestamp of last message). Uses a single query with correlated subqueries instead of N+2 queries. + + By default, child sessions (subagent runs, compression continuations) + are excluded. Pass ``include_children=True`` to include them. """ where_clauses = [] params = [] + if not include_children: + where_clauses.append("s.parent_session_id IS NULL") + if source: where_clauses.append("s.source = ?") params.append(source) @@ -1229,22 +1236,38 @@ class SessionDB: self._execute_write(_do) def delete_session(self, session_id: str) -> bool: - """Delete a session and all its messages. Returns True if found.""" + """Delete a session, its child sessions, and all their messages. + + Child sessions (subagent runs, compression continuations) are deleted + first to satisfy the ``parent_session_id`` foreign key constraint. + Returns True if the session was found and deleted. + """ def _do(conn): cursor = conn.execute( "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,) ) if cursor.fetchone()[0] == 0: return False + # Delete child sessions first (FK constraint) + child_ids = [r[0] for r in conn.execute( + "SELECT id FROM sessions WHERE parent_session_id = ?", + (session_id,), + ).fetchall()] + for cid in child_ids: + conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,)) + conn.execute("DELETE FROM sessions WHERE id = ?", (cid,)) + # Delete the session itself conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) return True return self._execute_write(_do) def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int: - """ - Delete sessions older than N days. Returns count of deleted sessions. - Only prunes ended sessions (not active ones). + """Delete sessions older than N days. Returns count of deleted sessions. + + Only prunes ended sessions (not active ones). Child sessions whose + parents are being pruned are deleted first to satisfy the + ``parent_session_id`` foreign key constraint. """ cutoff = time.time() - (older_than_days * 86400) @@ -1260,7 +1283,19 @@ class SessionDB: "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL", (cutoff,), ) - session_ids = [row["id"] for row in cursor.fetchall()] + session_ids = set(row["id"] for row in cursor.fetchall()) + + # Delete children first whose parents are in the prune set + # (avoids FK constraint errors) + for sid in list(session_ids): + child_ids = [r[0] for r in conn.execute( + "SELECT id FROM sessions WHERE parent_session_id = ?", + (sid,), + ).fetchall()] + for cid in child_ids: + conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,)) + conn.execute("DELETE FROM sessions WHERE id = ?", (cid,)) + session_ids.discard(cid) # don't double-delete for sid in session_ids: conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,)) diff --git a/run_agent.py b/run_agent.py index 050678928..af40344df 100644 --- a/run_agent.py +++ b/run_agent.py @@ -530,6 +530,7 @@ class AIAgent: skip_context_files: bool = False, skip_memory: bool = False, session_db=None, + parent_session_id: str = None, iteration_budget: "IterationBudget" = None, fallback_model: Dict[str, Any] = None, credential_pool=None, @@ -1025,6 +1026,7 @@ class AIAgent: # SQLite session store (optional -- provided by CLI or gateway) self._session_db = session_db + self._parent_session_id = parent_session_id self._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes if self._session_db: try: @@ -1038,6 +1040,7 @@ class AIAgent: "max_tokens": max_tokens, }, user_id=None, + parent_session_id=self._parent_session_id, ) except Exception as e: # Transient SQLite lock contention (e.g. CLI and gateway writing diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 8abf0b2d3..2a990d8f9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -251,6 +251,7 @@ def _build_child_agent( clarify_callback=None, thinking_callback=child_thinking_cb, session_db=getattr(parent_agent, '_session_db', None), + parent_session_id=getattr(parent_agent, 'session_id', None), providers_allowed=parent_agent.providers_allowed, providers_ignored=parent_agent.providers_ignored, providers_order=parent_agent.providers_order,