diff --git a/hermes_state.py b/hermes_state.py index 54cec8437a..6f6be056a1 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 050678928d..af40344df5 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 8abf0b2d39..2a990d8f93 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,