fix(cli): synchronize HERMES_SESSION_ID across environment and contextvar during session switches

This commit is contained in:
novax635 2026-05-23 21:14:15 +03:00 committed by Teknium
parent f63ef74eaf
commit 86871ee25a
7 changed files with 109 additions and 14 deletions

View file

@ -976,16 +976,14 @@ def init_agent(
# Expose session ID to tools (terminal, execute_code) so agents can
# reference their own session for --resume commands, cross-session
# coordination, and logging. Uses the ContextVar system from
# session_context.py for concurrency safety (gateway runs multiple
# sessions in one process). Also writes os.environ as fallback for
# CLI mode where ContextVars aren't used.
os.environ["HERMES_SESSION_ID"] = agent.session_id
# coordination, and logging. Keep the ContextVar and os.environ
# fallback synchronized because different tool paths still read both.
try:
from gateway.session_context import _SESSION_ID
_SESSION_ID.set(agent.session_id)
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
pass # CLI/test mode — ContextVar not needed
os.environ["HERMES_SESSION_ID"] = agent.session_id
# Session logs go into ~/.hermes/sessions/ alongside gateway sessions
hermes_home = get_hermes_home()

View file

@ -381,12 +381,12 @@ def compress_context(
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
os.environ["HERMES_SESSION_ID"] = agent.session_id
try:
from gateway.session_context import _SESSION_ID
_SESSION_ID.set(agent.session_id)
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
pass
os.environ["HERMES_SESSION_ID"] = agent.session_id
agent._session_db_created = False
agent._session_db.create_session(
session_id=agent.session_id,

14
cli.py
View file

@ -775,8 +775,6 @@ from rich.markup import escape as _escape
from rich.panel import Panel
from rich.text import Text as _RichText
import fire
# Import agent and tool systems lazily. Bare interactive startup only needs the
# prompt; the full agent/tool registry is initialized on first use.
def AIAgent(*args, **kwargs):
@ -818,6 +816,13 @@ def validate_toolset(*args, **kwargs):
return _validate_toolset(*args, **kwargs)
def _sync_process_session_id(session_id: str) -> None:
"""Keep process-local session-id consumers aligned after CLI switches."""
from gateway.session_context import set_current_session_id
set_current_session_id(session_id)
# Cron job system for scheduled tasks (execution is handled by the gateway)
def get_job(*args, **kwargs):
from cron import get_job as _get_job
@ -6281,6 +6286,7 @@ class HermesCLI:
self.conversation_history = []
self._pending_title = None
self._resumed = False
_sync_process_session_id(self.session_id)
if self.agent:
self.agent.session_id = self.session_id
@ -6567,6 +6573,7 @@ class HermesCLI:
self.session_id = target_id
self._resumed = True
self._pending_title = None
_sync_process_session_id(target_id)
# Load conversation history (strip transcript-only metadata entries)
restored = self._session_db.get_messages_as_conversation(target_id)
@ -6740,6 +6747,7 @@ class HermesCLI:
self.session_start = now
self._pending_title = None
self._resumed = True # Prevents auto-title generation
_sync_process_session_id(new_session_id)
# Sync the agent
if self.agent:
@ -14777,4 +14785,6 @@ def main(
if __name__ == "__main__":
import fire
fire.Fire(main)

View file

@ -83,6 +83,21 @@ _VAR_MAP = {
}
def set_current_session_id(session_id: str) -> None:
"""Synchronize ``HERMES_SESSION_ID`` across ContextVar and ``os.environ``.
Long-lived single-process entrypoints like the CLI can rotate sessions via
``/new``, ``/resume``, ``/branch``, or compression splits without
reconstructing the entire agent. Tools still consult
``get_session_env("HERMES_SESSION_ID")`` with an ``os.environ`` fallback,
so both storage paths must move together when the active session changes.
"""
import os
os.environ["HERMES_SESSION_ID"] = session_id
_SESSION_ID.set(session_id)
def set_session_vars(
platform: str = "",
chat_id: str = "",

View file

@ -168,6 +168,25 @@ class TestBranchCommandCLI:
assert cli_instance._resumed is True
def test_branch_rotates_hermes_session_id_env_and_context(self, cli_instance, session_db):
"""Branching must update process-local session-id readers too."""
from cli import HermesCLI
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
old_session_id = cli_instance.session_id
os.environ["HERMES_SESSION_ID"] = old_session_id
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
try:
HermesCLI._handle_branch_command(cli_instance, "/branch")
assert cli_instance.session_id != old_session_id
assert os.environ["HERMES_SESSION_ID"] == cli_instance.session_id
assert get_session_env("HERMES_SESSION_ID") == cli_instance.session_id
finally:
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_branch_fires_on_session_switch_hook(self, cli_instance, session_db):
"""The /branch command must notify memory providers of the rotation.

View file

@ -333,6 +333,33 @@ class TestHistoryDisplay:
assert "Checking Running Hermes Agent" in output
assert "Use /resume <session id or title> to continue" in output
def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
from hermes_state import SessionDB
cli = _make_cli()
cli.session_id = "current_session"
cli.conversation_history = []
cli.agent = None
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db.create_session("current_session", "cli")
cli._session_db.create_session("target_session", "cli")
cli._session_db.append_message("target_session", "user", "hello from resumed session")
os.environ["HERMES_SESSION_ID"] = "current_session"
_VAR_MAP["HERMES_SESSION_ID"].set("current_session")
try:
cli._handle_resume_command("/resume target_session")
assert cli.session_id == "target_session"
assert os.environ["HERMES_SESSION_ID"] == "target_session"
assert get_session_env("HERMES_SESSION_ID") == "target_session"
finally:
cli._session_db.close()
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
"""/sessions with no args prints the recent-sessions table (TUI parity).

View file

@ -8,6 +8,8 @@ import sys
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
from tools.todo_tool import TodoStore
@ -138,6 +140,15 @@ def _prepare_cli_with_active_session(tmp_path):
return cli
@pytest.fixture(autouse=True)
def _reset_session_id_context():
from gateway.session_context import _UNSET, _VAR_MAP
yield
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
@ -164,6 +175,21 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path)
cli.agent._invalidate_system_prompt.assert_called_once()
def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path):
from gateway.session_context import _VAR_MAP, get_session_env
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
os.environ["HERMES_SESSION_ID"] = old_session_id
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
cli.process_command("/new")
assert cli.session_id != old_session_id
assert os.environ["HERMES_SESSION_ID"] == cli.session_id
assert get_session_env("HERMES_SESSION_ID") == cli.session_id
def test_reset_command_is_alias_for_new_session(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id