mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
41ee207a5e
commit
db241ae6ce
7 changed files with 143 additions and 9 deletions
2
cli.py
2
cli.py
|
|
@ -2916,7 +2916,7 @@ class HermesCLI:
|
|||
try:
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source="cli",
|
||||
source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
model=self.model,
|
||||
model_config={
|
||||
"max_iterations": self.max_turns,
|
||||
|
|
|
|||
|
|
@ -513,6 +513,10 @@ def cmd_chat(args):
|
|||
if getattr(args, "yolo", False):
|
||||
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
|
||||
from cli import main as cli_main
|
||||
|
||||
|
|
@ -3170,6 +3174,11 @@ For more help on a command:
|
|||
default=False,
|
||||
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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -3868,8 +3877,12 @@ For more help on a command:
|
|||
|
||||
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":
|
||||
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:
|
||||
print("No sessions found.")
|
||||
return
|
||||
|
|
@ -3952,7 +3965,8 @@ For more help on a command:
|
|||
elif action == "browse":
|
||||
limit = getattr(args, "limit", 50) or 50
|
||||
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()
|
||||
if not sessions:
|
||||
print("No sessions found.")
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@ class SessionDB:
|
|||
def list_sessions_rich(
|
||||
self,
|
||||
source: str = None,
|
||||
exclude_sources: List[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
|
@ -583,7 +584,18 @@ class SessionDB:
|
|||
|
||||
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"""
|
||||
SELECT s.*,
|
||||
COALESCE(
|
||||
|
|
@ -598,11 +610,11 @@ class SessionDB:
|
|||
s.started_at
|
||||
) AS last_active
|
||||
FROM sessions s
|
||||
{source_clause}
|
||||
{where_sql}
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = (source, limit, offset) if source else (limit, offset)
|
||||
params.extend([limit, offset])
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
|
@ -818,6 +830,7 @@ class SessionDB:
|
|||
self,
|
||||
query: str,
|
||||
source_filter: List[str] = None,
|
||||
exclude_sources: List[str] = None,
|
||||
role_filter: List[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
|
|
@ -850,6 +863,11 @@ class SessionDB:
|
|||
where_clauses.append(f"s.source IN ({source_placeholders})")
|
||||
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:
|
||||
role_placeholders = ",".join("?" for _ in role_filter)
|
||||
where_clauses.append(f"m.role IN ({role_placeholders})")
|
||||
|
|
|
|||
|
|
@ -883,7 +883,7 @@ class AIAgent:
|
|||
try:
|
||||
self._session_db.create_session(
|
||||
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_config={
|
||||
"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_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source=self.platform or "cli",
|
||||
source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
model=self.model,
|
||||
parent_session_id=old_session_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,89 @@ class TestListSessionsRich:
|
|||
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:
|
||||
"""Tests for the main.py helper that resolves names or IDs."""
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from tools.session_search_tool import (
|
|||
_format_timestamp,
|
||||
_format_conversation,
|
||||
_truncate_around_matches,
|
||||
_HIDDEN_SESSION_SOURCES,
|
||||
MAX_SESSION_CHARS,
|
||||
SESSION_SEARCH_SCHEMA,
|
||||
)
|
||||
|
|
@ -17,6 +18,17 @@ from tools.session_search_tool import (
|
|||
# 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:
|
||||
def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self):
|
||||
description = SESSION_SEARCH_SCHEMA["description"]
|
||||
|
|
|
|||
|
|
@ -178,10 +178,16 @@ async def _summarize_session(
|
|||
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:
|
||||
"""Return metadata for the most recent sessions (no LLM calls)."""
|
||||
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
|
||||
current_root = None
|
||||
|
|
@ -265,6 +271,7 @@ def session_search(
|
|||
raw_results = db.search_messages(
|
||||
query=query,
|
||||
role_filter=role_list,
|
||||
exclude_sources=list(_HIDDEN_SESSION_SOURCES),
|
||||
limit=50, # Get more matches to find unique sessions
|
||||
offset=0,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue