From f5b277aa28cfd08b50d915c94840976641f576da Mon Sep 17 00:00:00 2001 From: Zev Waldman <4750208+waldmanz@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:21:31 -0400 Subject: [PATCH 1/2] feat(cron): pass job name as session title for cron sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron job sessions previously had no title in the SQLite session store, causing the web UI to display truncated system prompt text as session titles (e.g., '[SYSTEM: You are running as a scheduled cron job...]'). Pass the job's human-readable name through to SessionDB.create_session() so cron sessions display meaningful titles like 'wiki-auto-ingest', 'Hermes Tips', etc. in the sessions list, dashboard, and session search. Changes: - hermes_state.py: Add optional 'title' parameter to create_session() - run_agent.py: Accept 'session_title' in AIAgent.__init__ and forward it - cron/scheduler.py: Pass session_title=job_name when constructing AIAgent All parameters are optional with defaults that preserve existing behavior. No migration needed — the title column already exists in the schema. --- cron/scheduler.py | 1 + hermes_state.py | 6 ++++-- run_agent.py | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index d051a7ab36ed..a1281782e9bd 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -925,6 +925,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: platform="cron", session_id=_cron_session_id, session_db=_session_db, + session_title=job_name, ) # Run the agent with an *inactivity*-based timeout: the job can run diff --git a/hermes_state.py b/hermes_state.py index 0ea9815b5a15..d693dd826989 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -388,13 +388,14 @@ def create_session( system_prompt: str = None, user_id: str = None, parent_session_id: str = None, + title: str = None, ) -> str: """Create a new session record. Returns the session_id.""" def _do(conn): conn.execute( """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config, - system_prompt, parent_session_id, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + system_prompt, parent_session_id, title, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, source, @@ -403,6 +404,7 @@ def _do(conn): json.dumps(model_config) if model_config else None, system_prompt, parent_session_id, + title, time.time(), ), ) diff --git a/run_agent.py b/run_agent.py index affcbbd7218d..e39a5f142098 100644 --- a/run_agent.py +++ b/run_agent.py @@ -751,6 +751,7 @@ def __init__( skip_memory: bool = False, session_db=None, parent_session_id: str = None, + session_title: str = None, iteration_budget: "IterationBudget" = None, fallback_model: Dict[str, Any] = None, credential_pool=None, @@ -1381,6 +1382,7 @@ def __init__( # SQLite session store (optional -- provided by CLI or gateway) self._session_db = session_db self._parent_session_id = parent_session_id + self._session_title = session_title self._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes if self._session_db: try: @@ -1395,6 +1397,7 @@ def __init__( }, user_id=None, parent_session_id=self._parent_session_id, + title=self._session_title, ) except Exception as e: # Transient SQLite lock contention (e.g. CLI and gateway writing From a45a72706a64876489cdca802792264e1a4a1345 Mon Sep 17 00:00:00 2001 From: Zev Waldman <4750208+waldmanz@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:17:49 -0400 Subject: [PATCH 2/2] fix(cron): preserve unique session titles across repeated runs --- hermes_state.py | 53 ++++++++++++++++++++++++++++--- run_agent.py | 1 + tests/run_agent/test_run_agent.py | 23 ++++++++++++++ tests/test_hermes_state.py | 25 +++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index d693dd826989..daf3a604317b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -392,6 +392,7 @@ def create_session( ) -> str: """Create a new session record. Returns the session_id.""" def _do(conn): + resolved_title = self._allocate_unique_title(conn, title, session_id=session_id) conn.execute( """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config, system_prompt, parent_session_id, title, started_at) @@ -404,7 +405,7 @@ def _do(conn): json.dumps(model_config) if model_config else None, system_prompt, parent_session_id, - title, + resolved_title, time.time(), ), ) @@ -546,6 +547,7 @@ def ensure_session( session_id: str, source: str = "unknown", model: str = None, + title: str = None, ) -> None: """Ensure a session row exists, creating it with minimal metadata if absent. @@ -554,11 +556,12 @@ def ensure_session( INSERT OR IGNORE is safe to call even when the row already exists. """ def _do(conn): + resolved_title = self._allocate_unique_title(conn, title, session_id=session_id) conn.execute( """INSERT OR IGNORE INTO sessions - (id, source, model, started_at) - VALUES (?, ?, ?, ?)""", - (session_id, source, model, time.time()), + (id, source, model, title, started_at) + VALUES (?, ?, ?, ?, ?)""", + (session_id, source, model, resolved_title, time.time()), ) self._execute_write(_do) @@ -645,6 +648,48 @@ def sanitize_title(title: Optional[str]) -> Optional[str]: return cleaned + def _allocate_unique_title( + self, + conn: sqlite3.Connection, + title: Optional[str], + session_id: Optional[str] = None, + ) -> Optional[str]: + """Sanitize a title and allocate a unique lineage variant when needed.""" + sanitized = self.sanitize_title(title) + if sanitized is None: + return None + + params = [sanitized] + sql = "SELECT 1 FROM sessions WHERE title = ?" + if session_id is not None: + sql += " AND id != ?" + params.append(session_id) + + if conn.execute(sql, tuple(params)).fetchone() is None: + return sanitized + + match = re.match(r'^(.*?) #(\d+)$', sanitized) + base = match.group(1) if match else sanitized + escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + params = [base, f"{escaped} #%"] + sql = "SELECT title FROM sessions WHERE (title = ? OR title LIKE ? ESCAPE '\\')" + if session_id is not None: + sql += " AND id != ?" + params.append(session_id) + + existing = [row["title"] for row in conn.execute(sql, tuple(params)).fetchall()] + if not existing: + return base + + max_num = 1 + for existing_title in existing: + match = re.match(rf'^{re.escape(base)} #(\d+)$', existing_title) + if match: + max_num = max(max_num, int(match.group(1))) + + return f"{base} #{max_num + 1}" + def set_session_title(self, session_id: str, title: str) -> bool: """Set or update a session's title. diff --git a/run_agent.py b/run_agent.py index e39a5f142098..983975be8671 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3029,6 +3029,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo self.session_id, source=self.platform or "cli", model=self.model, + title=self._session_title, ) start_idx = len(conversation_history) if conversation_history else 0 flush_from = max(start_idx, self._last_flushed_db_idx) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d8f33f67c383..b7e17583b341 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3468,6 +3468,29 @@ def test_fresh_build_when_db_has_no_prompt(self, agent): # Empty string is falsy, so should fall through to fresh build assert "Hermes Agent" in agent._cached_system_prompt + +class TestSessionPersistence: + def test_flush_messages_to_session_db_passes_session_title_to_ensure_session(self, agent): + mock_db = MagicMock() + agent._session_db = mock_db + agent._session_title = "wiki-auto-ingest" + agent.session_id = "cron-session-1" + agent.platform = "cron" + agent.model = "test-model" + agent._last_flushed_db_idx = 0 + + messages = [{"role": "user", "content": "hello"}] + + agent._flush_messages_to_session_db(messages, conversation_history=[]) + + mock_db.ensure_session.assert_called_once_with( + "cron-session-1", + source="cron", + model="test-model", + title="wiki-auto-ingest", + ) + mock_db.append_message.assert_called_once() + class TestBudgetPressure: """Budget exhaustion grace call system.""" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f405cf8bd51b..9b39c638f450 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -989,6 +989,18 @@ def test_delete_orphans_children(self, db): # ========================================================================= class TestSessionTitle: + def test_create_session_with_duplicate_title_gets_numbered_variant(self, db): + db.create_session(session_id="s1", source="cron", title="wiki-auto-ingest") + db.create_session(session_id="s2", source="cron", title="wiki-auto-ingest") + + assert db.get_session("s1")["title"] == "wiki-auto-ingest" + assert db.get_session("s2")["title"] == "wiki-auto-ingest #2" + + def test_create_session_sanitizes_title_input(self, db): + db.create_session(session_id="s1", source="cron", title=" hello\t\nworld ") + + assert db.get_session("s1")["title"] == "hello world" + def test_set_and_get_title(self, db): db.create_session(session_id="s1", source="cli") assert db.set_session_title("s1", "My Session") is True @@ -1754,6 +1766,19 @@ def test_ensure_session_creates_missing_row(self, db): assert row["source"] == "gateway" assert row["model"] == "test-model" + def test_ensure_session_preserves_requested_title(self, db): + """Late recovery path should still keep the caller's requested title.""" + db.ensure_session( + "late-session", + source="cron", + model="gpt-4", + title="wiki-auto-ingest", + ) + + row = db.get_session("late-session") + assert row is not None + assert row["title"] == "wiki-auto-ingest" + def test_ensure_session_is_idempotent(self, db): """ensure_session on an existing row must be a no-op (no overwrite).""" db.create_session(session_id="existing", source="cli", model="original-model")