refactor(sessions): one session-id minter; QQ update-prompt key from build_session_key

Nine f-string sites minted `YYYYMMDD_HHMMSS_<hex>` independently with the hex width already
drifted (6 on CLI/TUI/agent/import, 8 in the gateway store, 12 in portability imports).
hermes_cli/session_lost_and_found.py classifies schema-less salvage rows by that shape, so a
site drifting the prefix would silently change recovery. hermes_state_ids.new_session_id(now,
hex_len=) is now the only writer and owns SESSION_ID_PATTERN; stdlib-only so agent/, cli.py and
gateway/ can import it without the SessionDB graph.

Widths are kept per site on purpose: the Desktop's session-id candidate regex is pinned to 6 hex
chars for interactive ids; the gateway store and portability importer keep 8/12 (more rows per
second). Not a bug, so not "fixed".

gateway/platforms/qqbot/adapter.py hard-coded `agent:main:qqbot:<scene>:<chat>` for the
update-prompt authz key, ignoring the profile namespace build_session_key applies; a secondary
bot in a multiplexed gateway got `agent:<profile>:...` keys and its clicks were rejected. The key
now comes from the one builder via BasePlatformAdapter._source_session_key.

Behavior change: QQ update-prompt clicks are authorized under the profile-namespaced key
(byte-identical `agent:main:` for the default profile).
This commit is contained in:
teknium1
2026-09-12 22:04:19 -07:00
committed by Teknium
parent c1e58f4cb2
commit 991b23ad8d
13 changed files with 35 additions and 24 deletions
+2 -4
View File
@@ -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.
+2 -1
View File
@@ -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,
+2 -2
View File
@@ -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"
+5 -2
View File
@@ -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)."""
+7 -1
View File
@@ -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:<session_key>:<decision>`` →
tools.approval.resolve_gateway_approval; ``update_prompt:<answer>`` →
@@ -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
)
+3 -1
View File
@@ -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]:
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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 = []
+3 -2
View File
@@ -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:
+2 -3
View File
@@ -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)
+1 -2
View File
@@ -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.
+2 -2
View File
@@ -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}])
+2 -1
View File
@@ -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):