feat(sessions): add --source flag for third-party session isolation (#3255)

When third-party tools (Paperclip orchestrator, etc.) spawn hermes chat
as a subprocess, their sessions pollute user session history and search.

- hermes chat --source <tag> (also HERMES_SESSION_SOURCE env var)
- exclude_sources parameter on list_sessions_rich() and search_messages()
- Sessions with source=tool hidden from sessions list/browse/search
- Third-party adapters pass --source tool to isolate agent sessions

Cherry-picked from PR #3208 by HenkDz.

Co-authored-by: Henkey <noonou7@gmail.com>
This commit is contained in:
Teknium
2026-03-26 14:35:31 -07:00
committed by GitHub
parent 41ee207a5e
commit db241ae6ce
7 changed files with 143 additions and 9 deletions

2
cli.py
View File

@@ -2916,7 +2916,7 @@ class HermesCLI:
try: try:
self._session_db.create_session( self._session_db.create_session(
session_id=self.session_id, session_id=self.session_id,
source="cli", source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=self.model, model=self.model,
model_config={ model_config={
"max_iterations": self.max_turns, "max_iterations": self.max_turns,

View File

@@ -513,6 +513,10 @@ def cmd_chat(args):
if getattr(args, "yolo", False): if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1" os.environ["HERMES_YOLO_MODE"] = "1"
# --source: tag session source for filtering (e.g. 'tool' for third-party integrations)
if getattr(args, "source", None):
os.environ["HERMES_SESSION_SOURCE"] = args.source
# Import and run the CLI # Import and run the CLI
from cli import main as cli_main from cli import main as cli_main
@@ -3170,6 +3174,11 @@ For more help on a command:
default=False, default=False,
help="Include the session ID in the agent's system prompt" help="Include the session ID in the agent's system prompt"
) )
chat_parser.add_argument(
"--source",
default=None,
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists."
)
chat_parser.set_defaults(func=cmd_chat) chat_parser.set_defaults(func=cmd_chat)
# ========================================================================= # =========================================================================
@@ -3868,8 +3877,12 @@ For more help on a command:
action = args.sessions_action action = args.sessions_action
# Hide third-party tool sessions by default, but honour explicit --source
_source = getattr(args, "source", None)
_exclude = None if _source else ["tool"]
if action == "list": if action == "list":
sessions = db.list_sessions_rich(source=args.source, limit=args.limit) sessions = db.list_sessions_rich(source=args.source, exclude_sources=_exclude, limit=args.limit)
if not sessions: if not sessions:
print("No sessions found.") print("No sessions found.")
return return
@@ -3952,7 +3965,8 @@ For more help on a command:
elif action == "browse": elif action == "browse":
limit = getattr(args, "limit", 50) or 50 limit = getattr(args, "limit", 50) or 50
source = getattr(args, "source", None) source = getattr(args, "source", None)
sessions = db.list_sessions_rich(source=source, limit=limit) _browse_exclude = None if source else ["tool"]
sessions = db.list_sessions_rich(source=source, exclude_sources=_browse_exclude, limit=limit)
db.close() db.close()
if not sessions: if not sessions:
print("No sessions found.") print("No sessions found.")

View File

@@ -572,6 +572,7 @@ class SessionDB:
def list_sessions_rich( def list_sessions_rich(
self, self,
source: str = None, source: str = None,
exclude_sources: List[str] = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@@ -583,7 +584,18 @@ class SessionDB:
Uses a single query with correlated subqueries instead of N+2 queries. Uses a single query with correlated subqueries instead of N+2 queries.
""" """
source_clause = "WHERE s.source = ?" if source else "" where_clauses = []
params = []
if source:
where_clauses.append("s.source = ?")
params.append(source)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
query = f""" query = f"""
SELECT s.*, SELECT s.*,
COALESCE( COALESCE(
@@ -598,11 +610,11 @@ class SessionDB:
s.started_at s.started_at
) AS last_active ) AS last_active
FROM sessions s FROM sessions s
{source_clause} {where_sql}
ORDER BY s.started_at DESC ORDER BY s.started_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""" """
params = (source, limit, offset) if source else (limit, offset) params.extend([limit, offset])
with self._lock: with self._lock:
cursor = self._conn.execute(query, params) cursor = self._conn.execute(query, params)
rows = cursor.fetchall() rows = cursor.fetchall()
@@ -818,6 +830,7 @@ class SessionDB:
self, self,
query: str, query: str,
source_filter: List[str] = None, source_filter: List[str] = None,
exclude_sources: List[str] = None,
role_filter: List[str] = None, role_filter: List[str] = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
@@ -850,6 +863,11 @@ class SessionDB:
where_clauses.append(f"s.source IN ({source_placeholders})") where_clauses.append(f"s.source IN ({source_placeholders})")
params.extend(source_filter) params.extend(source_filter)
if exclude_sources is not None:
exclude_placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({exclude_placeholders})")
params.extend(exclude_sources)
if role_filter: if role_filter:
role_placeholders = ",".join("?" for _ in role_filter) role_placeholders = ",".join("?" for _ in role_filter)
where_clauses.append(f"m.role IN ({role_placeholders})") where_clauses.append(f"m.role IN ({role_placeholders})")

View File

@@ -883,7 +883,7 @@ class AIAgent:
try: try:
self._session_db.create_session( self._session_db.create_session(
session_id=self.session_id, session_id=self.session_id,
source=self.platform or "cli", source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=self.model, model=self.model,
model_config={ model_config={
"max_iterations": self.max_iterations, "max_iterations": self.max_iterations,
@@ -4859,7 +4859,7 @@ class AIAgent:
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
self._session_db.create_session( self._session_db.create_session(
session_id=self.session_id, session_id=self.session_id,
source=self.platform or "cli", source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=self.model, model=self.model,
parent_session_id=old_session_id, parent_session_id=old_session_id,
) )

View File

@@ -1102,6 +1102,89 @@ class TestListSessionsRich:
assert "Line one Line two" in sessions[0]["preview"] assert "Line one Line two" in sessions[0]["preview"]
# =========================================================================
# Session source exclusion (--source flag for third-party isolation)
# =========================================================================
class TestExcludeSources:
"""Tests for exclude_sources on list_sessions_rich and search_messages."""
def test_list_sessions_rich_excludes_tool_source(self, db):
db.create_session("s1", "cli")
db.create_session("s2", "tool")
db.create_session("s3", "telegram")
sessions = db.list_sessions_rich(exclude_sources=["tool"])
ids = [s["id"] for s in sessions]
assert "s1" in ids
assert "s3" in ids
assert "s2" not in ids
def test_list_sessions_rich_no_exclusion_returns_all(self, db):
db.create_session("s1", "cli")
db.create_session("s2", "tool")
sessions = db.list_sessions_rich()
ids = [s["id"] for s in sessions]
assert "s1" in ids
assert "s2" in ids
def test_list_sessions_rich_source_and_exclude_combined(self, db):
"""When source= is explicit, exclude_sources should not conflict."""
db.create_session("s1", "cli")
db.create_session("s2", "tool")
db.create_session("s3", "telegram")
# Explicit source filter: only tool sessions, no exclusion
sessions = db.list_sessions_rich(source="tool")
ids = [s["id"] for s in sessions]
assert ids == ["s2"]
def test_list_sessions_rich_exclude_multiple_sources(self, db):
db.create_session("s1", "cli")
db.create_session("s2", "tool")
db.create_session("s3", "cron")
db.create_session("s4", "telegram")
sessions = db.list_sessions_rich(exclude_sources=["tool", "cron"])
ids = [s["id"] for s in sessions]
assert "s1" in ids
assert "s4" in ids
assert "s2" not in ids
assert "s3" not in ids
def test_search_messages_excludes_tool_source(self, db):
db.create_session("s1", "cli")
db.append_message("s1", "user", "Python deployment question")
db.create_session("s2", "tool")
db.append_message("s2", "user", "Python automated question")
results = db.search_messages("Python", exclude_sources=["tool"])
sources = [r["source"] for r in results]
assert "cli" in sources
assert "tool" not in sources
def test_search_messages_no_exclusion_returns_all_sources(self, db):
db.create_session("s1", "cli")
db.append_message("s1", "user", "Rust deployment question")
db.create_session("s2", "tool")
db.append_message("s2", "user", "Rust automated question")
results = db.search_messages("Rust")
sources = [r["source"] for r in results]
assert "cli" in sources
assert "tool" in sources
def test_search_messages_source_include_and_exclude(self, db):
"""source_filter (include) and exclude_sources can coexist."""
db.create_session("s1", "cli")
db.append_message("s1", "user", "Golang test")
db.create_session("s2", "telegram")
db.append_message("s2", "user", "Golang test")
db.create_session("s3", "tool")
db.append_message("s3", "user", "Golang test")
# Include cli+tool, but exclude tool → should only return cli
results = db.search_messages(
"Golang", source_filter=["cli", "tool"], exclude_sources=["tool"]
)
sources = [r["source"] for r in results]
assert sources == ["cli"]
class TestResolveSessionByNameOrId: class TestResolveSessionByNameOrId:
"""Tests for the main.py helper that resolves names or IDs.""" """Tests for the main.py helper that resolves names or IDs."""

View File

@@ -8,6 +8,7 @@ from tools.session_search_tool import (
_format_timestamp, _format_timestamp,
_format_conversation, _format_conversation,
_truncate_around_matches, _truncate_around_matches,
_HIDDEN_SESSION_SOURCES,
MAX_SESSION_CHARS, MAX_SESSION_CHARS,
SESSION_SEARCH_SCHEMA, SESSION_SEARCH_SCHEMA,
) )
@@ -17,6 +18,17 @@ from tools.session_search_tool import (
# Tool schema guidance # Tool schema guidance
# ========================================================================= # =========================================================================
class TestHiddenSessionSources:
"""Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation."""
def test_tool_source_is_hidden(self):
assert "tool" in _HIDDEN_SESSION_SOURCES
def test_standard_sources_not_hidden(self):
for src in ("cli", "telegram", "discord", "slack", "cron"):
assert src not in _HIDDEN_SESSION_SOURCES
class TestSessionSearchSchema: class TestSessionSearchSchema:
def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self): def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self):
description = SESSION_SEARCH_SCHEMA["description"] description = SESSION_SEARCH_SCHEMA["description"]

View File

@@ -178,10 +178,16 @@ async def _summarize_session(
return None return None
# Sources that are excluded from session browsing/searching by default.
# Third-party integrations (Paperclip agents, etc.) tag their sessions with
# HERMES_SESSION_SOURCE=tool so they don't clutter the user's session history.
_HIDDEN_SESSION_SOURCES = ("tool",)
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
"""Return metadata for the most recent sessions (no LLM calls).""" """Return metadata for the most recent sessions (no LLM calls)."""
try: try:
sessions = db.list_sessions_rich(limit=limit + 5) # fetch extra to skip current sessions = db.list_sessions_rich(limit=limit + 5, exclude_sources=list(_HIDDEN_SESSION_SOURCES)) # fetch extra to skip current
# Resolve current session lineage to exclude it # Resolve current session lineage to exclude it
current_root = None current_root = None
@@ -265,6 +271,7 @@ def session_search(
raw_results = db.search_messages( raw_results = db.search_messages(
query=query, query=query,
role_filter=role_list, role_filter=role_list,
exclude_sources=list(_HIDDEN_SESSION_SOURCES),
limit=50, # Get more matches to find unique sessions limit=50, # Get more matches to find unique sessions
offset=0, offset=0,
) )