diff --git a/agent/agent_init.py b/agent/agent_init.py index f514877cf0..06bd3f1a07 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -15,7 +15,6 @@ import re import sys import threading import time -import uuid from collections import deque from contextlib import suppress from datetime import datetime @@ -41,6 +40,7 @@ from hermes_cli.config import cfg_get from hermes_cli.route_identity import normalize_route_base_url from hermes_cli.timeouts import get_provider_request_timeout from hermes_constants import get_hermes_home +from hermes_state_ids import new_session_id from utils import base_url_host_matches, is_truthy_value # Same logger name as run_agent so caplog/patches on "run_agent" see our records. @@ -1122,9 +1122,7 @@ def _publish_session_id(session_id: str) -> None: def _init_session_state(agent, session_id, session_db, parent_session_id, reasoning_config, max_tokens, checkpoints_enabled, checkpoint_max_snapshots, checkpoint_max_total_size_mb, checkpoint_max_file_size_mb): agent.session_start = datetime.now() - agent.session_id = session_id or ( - f"{agent.session_start.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - ) + agent.session_id = session_id or new_session_id(agent.session_start) _publish_session_id(agent.session_id) # ~/.hermes/sessions/ — kept unconditionally for request_dump_*.json debug breadcrumbs. diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 1f49aa9060..3bf4a23238 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -33,6 +33,7 @@ from agent.memory_provider import PRE_COMPRESS_CHECKPOINT_API_VERSION from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough from agent.session_activity import ActivityProvenance, normalize_activity_provenance from agent.usage_anchor import set_usage_anchor +from hermes_state_ids import new_session_id as mint_session_id logger = logging.getLogger(__name__) @@ -2980,7 +2981,7 @@ def _publish_rotated_compaction( if _profile_for_child == "default": _profile_for_child = None old_title = agent._session_db.get_session_title(agent.session_id) - new_session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + new_session_id = mint_session_id() from agent.context_compressor import _DB_PERSISTED_MARKER agent._session_db.publish_compression_child( parent_session_id=old_session_id, child_session_id=new_session_id, diff --git a/cli.py b/cli.py index 389a1472b0..86980375fc 100644 --- a/cli.py +++ b/cli.py @@ -17,7 +17,6 @@ import re import atexit import errno import time -import uuid import textwrap from collections import deque from dataclasses import dataclass @@ -170,6 +169,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧ # ~/.hermes/.env first, project .env as dev fallback; user env files override stale shell exports. from hermes_constants import get_hermes_home +from hermes_state_ids import new_session_id from hermes_cli.env_loader import load_hermes_dotenv from utils import base_url_host_matches, base_url_hostname, fast_safe_load @@ -2827,7 +2827,7 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix self._init_session_store() self._pending_title: Optional[str] = None self._resumed = bool(resume) - self.session_id = resume or f"{self.session_start.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + self.session_id = resume or new_session_id(self.session_start) getattr(self, "_write_terminal_breadcrumb", lambda: None)() self._history_file = _hermes_home / ".hermes_history" diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index df8e736189..23337e02f9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2240,11 +2240,14 @@ class BasePlatformAdapter(ABC): def _event_session_key(self, event: "MessageEvent") -> str: """Adapter-level session key for ``event``, profile-namespaced like the agent run.""" + return self._source_session_key(event.source) + + def _source_session_key(self, source: "SessionSource") -> str: extra = self.config.extra return build_session_key( - event.source, group_sessions_per_user=extra.get("group_sessions_per_user", True), + source, group_sessions_per_user=extra.get("group_sessions_per_user", True), thread_sessions_per_user=extra.get("thread_sessions_per_user", False), - profile=self._session_key_profile(event.source)) + profile=self._session_key_profile(source)) def _text_batch_key(self, event: "MessageEvent") -> str: """Session-scoped key for text batching (subclasses may override).""" diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index ee649dcf62..e402373b4e 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -685,6 +685,12 @@ class QQAdapter(BasePlatformAdapter): return bool(session_user) and operator == session_user return False + def _update_prompt_session_key(self, event: InteractionEvent, chat: str) -> str: + """Session key an update-prompt click is authorized against, built by the ONE key builder so + it carries the profile namespace (a hard-coded ``agent:main:`` prefix never matched a + multiplexed secondary bot's lane). No participant: ``c2c`` authorizes on chat == operator.""" + return self._source_session_key(self.build_source(chat_id=chat, chat_type=event.scene)) + async def _default_interaction_dispatch(self, event: InteractionEvent) -> None: """Default interaction callback: ``approve::`` → tools.approval.resolve_gateway_approval; ``update_prompt:`` → @@ -718,7 +724,7 @@ class QQAdapter(BasePlatformAdapter): update_answer = parse_update_prompt_button_data(button_data) if update_answer is not None: chat = event.group_openid or event.guild_id or event.user_openid - if not self._is_authorized_interaction_for_session(event, f"agent:main:qqbot:{event.scene}:{chat}"): + if not self._is_authorized_interaction_for_session(event, self._update_prompt_session_key(event, chat)): logger.warning( "[%s] Rejected unauthorized update prompt click (operator=%s)", self._log_tag, event.operator_openid ) diff --git a/gateway/session_lifecycle.py b/gateway/session_lifecycle.py index 1c037af626..47b3a95489 100644 --- a/gateway/session_lifecycle.py +++ b/gateway/session_lifecycle.py @@ -8,6 +8,8 @@ import uuid from datetime import datetime, timedelta from typing import TYPE_CHECKING, Optional +from hermes_state_ids import new_session_id + if TYPE_CHECKING: from gateway.session import SessionEntry, SessionSource @@ -21,7 +23,7 @@ def _now() -> datetime: def _new_session_id(now: datetime) -> str: - return f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + return new_session_id(now, hex_len=8) def _iso(dt: Optional[datetime]) -> Optional[str]: diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f0d3743e77..bca45fcf51 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -29,6 +29,7 @@ from rich.markup import escape as _escape from rich.panel import Panel from hermes_constants import display_hermes_home, is_termux as _is_termux_environment +from hermes_state_ids import new_session_id as mint_session_id from agent.turn_context import extract_api_content_sidecar from hermes_cli.browser_connect import ( DEFAULT_BROWSER_CDP_URL, discover_local_cdp_url, find_free_debug_port, is_browser_debug_ready, @@ -1373,7 +1374,7 @@ class CLICommandsMixin: return _cp(_db_unavailable_line()) branch_name = _command_arg(cmd_original) now = datetime.now() - new_session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + new_session_id = mint_session_id(now) branch_title = branch_name or self._session_db.get_next_title_in_lineage( self._session_db.get_session_title(self.session_id) or "branch") parent_session_id = self.session_id diff --git a/hermes_cli/cli_session_mixin.py b/hermes_cli/cli_session_mixin.py index b807483cf1..5a3ca23afe 100644 --- a/hermes_cli/cli_session_mixin.py +++ b/hermes_cli/cli_session_mixin.py @@ -11,9 +11,9 @@ import contextlib import os import shutil import sys -import uuid from hermes_constants import get_hermes_home +from hermes_state_ids import new_session_id from pathlib import Path from rich.console import Console from rich.markup import escape as _escape @@ -536,7 +536,7 @@ class CLISessionMixin: self._discard_session_if_empty(old_session_id) self.session_start = datetime.now() - self.session_id = f"{self.session_start.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + self.session_id = new_session_id(self.session_start) # getattr: tests drive new_session unbound against a SimpleNamespace stand-in. getattr(self, "_write_terminal_breadcrumb", lambda: None)() self.conversation_history = [] diff --git a/hermes_cli/foreign_sessions.py b/hermes_cli/foreign_sessions.py index 823a44df8f..c27d9f0113 100644 --- a/hermes_cli/foreign_sessions.py +++ b/hermes_cli/foreign_sessions.py @@ -8,13 +8,14 @@ import json import os import re import sys -import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path from stat import S_ISREG from typing import Any, Dict, List, Optional, Tuple +from hermes_state_ids import new_session_id + # User-message texts that are really injected context wrappers, not typed input. _WRAPPER_TAG_RE = re.compile( r"^<(?:user_instructions|environment_context|recommended_plugins|" @@ -244,7 +245,7 @@ def import_foreign_session(source: str, path, db=None) -> str: from hermes_state import SessionDB db = SessionDB() try: - session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + session_id = new_session_id() origin = {"imported_from": {"tool": tool, "path": str(path), "foreign_session_id": parsed.get("session_id")}} db.create_session(session_id, source=tool, cwd=parsed.get("cwd"), origin_json=json.dumps(origin)) for turn in turns: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b11f7c1599..31c95eec3c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1334,11 +1334,10 @@ def _create_titled_session(title: str) -> Optional[str]: """ db = None try: - import uuid as _uuid - from hermes_state import SessionDB + from hermes_state_ids import new_session_id as mint_session_id - new_session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:6]}" + new_session_id = mint_session_id() db = SessionDB() db.create_session(new_session_id, source="cli") db.set_session_title(new_session_id, title) diff --git a/hermes_cli/session_lost_and_found.py b/hermes_cli/session_lost_and_found.py index d915532c24..d4c354c3fc 100644 --- a/hermes_cli/session_lost_and_found.py +++ b/hermes_cli/session_lost_and_found.py @@ -15,6 +15,7 @@ from typing import Any, Callable, Optional, Sequence from hermes_cli.session_schema_history import SCHEMA_HISTORY, reachable_physical_layouts +from hermes_state_ids import SESSION_ID_PATTERN # timestamp prefix: strongest sentinel for schema-less rows from hermes_cli.session_recovery import ( _AUXILIARY_TABLE_SCHEMAS, _AUXILIARY_TABLES, _CANONICAL_TABLES, _count_rows, _immediate_transaction, _placeholder_titles, _quoted_columns, _table_columns, @@ -22,8 +23,6 @@ from hermes_cli.session_recovery import ( logger = logging.getLogger(__name__) -# Hermes session ids are timestamps (20260812_135332_ab12cd): the strongest sentinel for schema-less rows. -SESSION_ID_PATTERN = re.compile(r"^\d{8}_\d{6}_") MESSAGE_ROLES = frozenset({"user", "assistant", "tool", "system"}) # Values observed in sessions.source across gateway platforms and tooling. diff --git a/hermes_state_portability.py b/hermes_state_portability.py index f455ceadd2..2a960ec038 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional from agent.skill_commands import SKILL_SCAFFOLD_SQL_LIKE from utils import safe_json_loads from hermes_cli.timefmt import coerce_epoch +from hermes_state_ids import new_session_id from hermes_state_common import SCHEMA_SQL, _PREVIEW_RAW_SUBQUERY_SQL, _shape_preview, _sql_session_last_active # Pre-split logger identity so log filtering/capture is unchanged. @@ -106,8 +107,7 @@ class SessionPortabilityMixin: Reuse the portability validator and message writer so counters and FTS obey the same contract as ordinary transcript imports. """ - import uuid - session_id = f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:12]}" + session_id = new_session_id(hex_len=12) normalized, errors = self._validate_import_payload([ {"id": session_id, "source": origin["tool"], "title": title, "cwd": cwd, "messages": messages}]) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e1837564ed..e0c848d454 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -27,6 +27,7 @@ from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override) from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value +from hermes_state_ids import new_session_id from tools.environments.local import hermes_subprocess_env from agent.replay_cleanup import sanitize_replay_history from agent.compaction_display import project_compaction_message_for_display # noqa: F401 @@ -2367,7 +2368,7 @@ def _init_session( def _new_session_key() -> str: - return f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + return new_session_id() def _with_checkpoints(session, fn):