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:
2
cli.py
2
cli.py
@@ -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,
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user