Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ def _ra():
return run_agent


def _generate_session_id(now: Optional[datetime] = None) -> str:
"""Mint a session id: ``YYYYMMDD_HHMMSS_<24-bit hex>``.

The 6-hex suffix is only 24 bits, so the id space is ~33.5M — birthday
collisions grow as P ≈ N²/33.5M (~12% at 2,000 sessions). The collision
contract is enforced at the persistence layer:

* ``create_session`` / ``_insert_session_row`` upsert
(``ON CONFLICT(id) DO UPDATE``) — never raises on collision; the
hermes_state collision warning covers visibility there.
* The compression-rotation child is inserted with a *plain* INSERT
(``publish_compression_child``) and raises ``sqlite3.IntegrityError``;
``conversation_compression.py`` catches it and retries with a fresh id
(max 3 attempts) instead of aborting the boundary.
"""
base = now or datetime.now()
return f"{base.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"


def _moa_reference_output_allowed(agent: Any) -> bool:
"""Keep MoA display events off only the machine-readable ``-Q`` surface."""
return not (
Expand Down Expand Up @@ -1491,10 +1510,10 @@ def init_agent(
# Use provided session ID (e.g., from CLI)
agent.session_id = session_id
else:
# Generate a new session ID
timestamp_str = agent.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
agent.session_id = f"{timestamp_str}_{short_uuid}"
# Generate a new session ID (24-bit hex suffix — collision contract
# documented in _generate_session_id; persistence-layer retries live
# in conversation_compression.py for the plain-INSERT child path).
agent.session_id = _generate_session_id(now=agent.session_start)

# Expose session ID to tools (terminal, execute_code) so agents can
# reference their own session for --resume commands, cross-session
Expand Down
63 changes: 45 additions & 18 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import logging
import math
import os
import sqlite3
import tempfile
import time
import uuid
Expand Down Expand Up @@ -3251,24 +3252,50 @@ def _release_lock() -> None:
_profile_for_child = None
old_title = agent._session_db.get_session_title(agent.session_id)
old_session_id = agent.session_id
new_session_id = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
f"{uuid.uuid4().hex[:6]}"
)
agent._session_db.publish_compression_child(
parent_session_id=old_session_id,
child_session_id=new_session_id,
source=agent.platform
or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
system_prompt=new_system_prompt,
messages=compressed,
cwd=getattr(agent, "working_directory", None),
profile_name=_profile_for_child,
compression_lock_holder=_lock_holder,
require_compression_lease=_lock_holder is not None,
)
# ── Collision-safe child id (N4) ─────────────────────
# publish_compression_child inserts the child row with a
# plain INSERT (see hermes_state.publish_compression_child),
# so a 24-bit hex suffix collision (P ≈ N²/33.5M — ~12% at
# 2,000 sessions) raises sqlite3.IntegrityError and would
# abort compression at the boundary. Retry with a
# regenerated id (fresh timestamp + fresh 24-bit random),
# max 3 attempts. Each attempt runs in its own transaction
# (BEGIN IMMEDIATE + rollback on error), so a failed insert
# leaves no partial child/parent state to clean up.
new_session_id = None
for _child_attempt in range(1, 4):
new_session_id = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
f"{uuid.uuid4().hex[:6]}"
)
try:
agent._session_db.publish_compression_child(
parent_session_id=old_session_id,
child_session_id=new_session_id,
source=agent.platform
or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
system_prompt=new_system_prompt,
messages=compressed,
cwd=getattr(agent, "working_directory", None),
profile_name=_profile_for_child,
compression_lock_holder=_lock_holder,
require_compression_lease=_lock_holder is not None,
)
break # child published — id is durable
except sqlite3.IntegrityError:
if _child_attempt >= 3:
raise
logger.warning(
"Session id collision on compression child "
"(%s); regenerating (attempt %d/3)",
new_session_id,
_child_attempt + 1,
)
# Tiny sleep so a same-second retry gets a fresh
# timestamp, shrinking re-collision odds further.
time.sleep(0.05)
agent.session_id = new_session_id
try:
from gateway.session_context import set_current_session_id
Expand Down
34 changes: 32 additions & 2 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,13 +563,43 @@ def get_external_skills_dirs() -> List[Path]:
return result


def get_workspace_skills_dirs() -> List[Path]:
"""Return workspace-local skill directories for the current session.

Reads the active session context (platform + chat_id + thread_id) and
discovers any workspace-local ``skills/`` directories under the resolved
workspace folder. These are injected into ``get_all_skills_dirs()``
so workspace-specific skills are available without global installation.

Uses a 1-second stat cache so changes are picked up automatically —
no gateway restart required.
"""
try:
from gateway.session_context import get_session_env
except ImportError:
return []
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
if not platform or not chat_id:
return []
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None
try:
from agent.workspace_resolver import get_workspace_skill_dirs
except ImportError:
return []
return get_workspace_skill_dirs(platform, chat_id, thread_id)


def get_all_skills_dirs() -> List[Path]:
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
"""Return all skill directories: local ``~/.hermes/skills/`` first, then
workspace-local, then external.

The local dir is always first (and always included even if it doesn't exist
yet — callers handle that). External dirs follow in config order.
yet — callers handle that). Workspace-local dirs follow so per-topic skills
can override global/external ones. External dirs come last in config order.
"""
dirs = [get_skills_dir()]
dirs.extend(get_workspace_skills_dirs())
dirs.extend(get_external_skills_dirs())
return dirs

Expand Down
Loading
Loading