Skip to content
Open
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
4 changes: 4 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ def init_agent(
chat_type: str = None,
thread_id: str = None,
gateway_session_key: str = None,
gateway_recall_scope_key: str = None,
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
Expand Down Expand Up @@ -307,6 +308,7 @@ def init_agent(
agent._chat_type = chat_type
agent._thread_id = thread_id
agent._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123)
agent._gateway_recall_scope_key = gateway_recall_scope_key # Stable current-chat/thread recall scope; excludes per-user isolation.
# Pluggable print function — CLI replaces this with _cprint so that
# raw ANSI status lines are routed through prompt_toolkit's renderer
# instead of going directly to stdout where patch_stdout's StdoutProxy
Expand Down Expand Up @@ -1272,6 +1274,8 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
# Thread gateway session key for stable per-chat Honcho session isolation
if agent._gateway_session_key:
_init_kwargs["gateway_session_key"] = agent._gateway_session_key
if agent._gateway_recall_scope_key:
_init_kwargs["gateway_recall_scope_key"] = agent._gateway_recall_scope_key
# Profile identity for per-profile provider scoping
try:
from hermes_cli.profiles import get_active_profile_name
Expand Down
17 changes: 17 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1933,6 +1933,19 @@ def _execute(next_args: dict) -> Any:
from hermes_state import format_session_db_unavailable
return _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()}), next_args)
from tools.session_search_tool import session_search as _session_search
current_scope_key = getattr(agent, "_gateway_recall_scope_key", None)
gateway_context = False
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
gateway_context = bool(platform)
if not platform:
current_scope_key = None
elif not current_scope_key:
current_scope_key = get_session_env("HERMES_RECALL_SCOPE_KEY", "") or None
except Exception:
current_scope_key = None
gateway_context = False
return _finish_agent_tool(
_session_search(
query=next_args.get("query", ""),
Expand All @@ -1942,8 +1955,12 @@ def _execute(next_args: dict) -> Any:
around_message_id=next_args.get("around_message_id"),
window=next_args.get("window", 5),
sort=next_args.get("sort"),
scope=next_args.get("scope"),
profile=str(next_args.get("profile") or ""),
db=session_db,
current_session_id=agent.session_id,
current_scope_key=current_scope_key,
gateway_context=gateway_context,
),
next_args,
)
Expand Down
17 changes: 17 additions & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,19 @@ def _execute(next_args: dict) -> Any:
from hermes_state import format_session_db_unavailable
return json.dumps({"success": False, "error": format_session_db_unavailable()})
from tools.session_search_tool import session_search as _session_search
current_scope_key = getattr(agent, "_gateway_recall_scope_key", None)
gateway_context = False
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
gateway_context = bool(platform)
if not platform:
current_scope_key = None
elif not current_scope_key:
current_scope_key = get_session_env("HERMES_RECALL_SCOPE_KEY", "") or None
except Exception:
current_scope_key = None
gateway_context = False
return _session_search(
query=next_args.get("query", ""),
role_filter=next_args.get("role_filter"),
Expand All @@ -1090,8 +1103,12 @@ def _execute(next_args: dict) -> Any:
around_message_id=next_args.get("around_message_id"),
window=next_args.get("window", 5),
sort=next_args.get("sort"),
scope=next_args.get("scope"),
profile=str(next_args.get("profile") or ""),
db=session_db,
current_session_id=agent.session_id,
current_scope_key=current_scope_key,
gateway_context=gateway_context,
)
function_result, function_args = _run_agent_tool_execution_middleware(
agent,
Expand Down
45 changes: 29 additions & 16 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,7 @@ def _profile_runtime_scope(profile_home: "Path"):
SessionContext,
build_session_context,
build_session_context_prompt,
build_recall_scope_key,
build_session_key,
is_shared_multi_user_session,
)
Expand Down Expand Up @@ -10327,6 +10328,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
source=source,
session_id=session_entry.session_id,
session_key=session_key,
recall_scope_key=context.recall_scope_key,
run_generation=run_generation,
event_message_id=self._reply_anchor_for_event(event),
channel_prompt=event.channel_prompt,
Expand Down Expand Up @@ -13520,6 +13522,7 @@ def _set_session_env(self, context: SessionContext) -> list:
user_id=str(context.source.user_id) if context.source.user_id else "",
user_name=str(context.source.user_name) if context.source.user_name else "",
session_key=context.session_key,
recall_scope_key=context.recall_scope_key,
message_id=str(context.source.message_id) if context.source.message_id else "",
async_delivery=_async_delivery,
)
Expand Down Expand Up @@ -15195,6 +15198,7 @@ async def _run_agent(
source: SessionSource,
session_id: str,
session_key: str = None,
recall_scope_key: str = None,
run_generation: Optional[int] = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
Expand All @@ -15215,7 +15219,8 @@ async def _run_agent(
if not getattr(getattr(self, "config", None), "multiplex_profiles", False):
return await self._run_agent_inner(
message, context_prompt, history, source, session_id,
session_key=session_key, run_generation=run_generation,
session_key=session_key, recall_scope_key=recall_scope_key,
run_generation=run_generation,
_interrupt_depth=_interrupt_depth, event_message_id=event_message_id,
channel_prompt=channel_prompt, moa_config=moa_config,
persist_user_message=persist_user_message,
Expand All @@ -15226,7 +15231,8 @@ async def _run_agent(
with _profile_runtime_scope(profile_home):
return await self._run_agent_inner(
message, context_prompt, history, source, session_id,
session_key=session_key, run_generation=run_generation,
session_key=session_key, recall_scope_key=recall_scope_key,
run_generation=run_generation,
_interrupt_depth=_interrupt_depth, event_message_id=event_message_id,
channel_prompt=channel_prompt, moa_config=moa_config,
persist_user_message=persist_user_message,
Expand Down Expand Up @@ -15256,6 +15262,7 @@ async def _run_agent_inner(
source: SessionSource,
session_id: str,
session_key: str = None,
recall_scope_key: str = None,
run_generation: Optional[int] = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
Expand Down Expand Up @@ -15292,6 +15299,12 @@ async def _run_agent_inner(
from run_agent import AIAgent
import queue

if not recall_scope_key:
_profile = None
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
_profile = source.profile
recall_scope_key = build_recall_scope_key(source, profile=_profile)

def _run_still_current() -> bool:
if run_generation is None or not session_key:
return True
Expand Down Expand Up @@ -16102,20 +16115,18 @@ def run_sync():
# `_resolve_turn_agent_config(message, …)`.
nonlocal message

# session_key is propagated via contextvars in _set_session_env()
# (_SESSION_KEY) and via set_current_session_key() (_approval_session_key)
# below — both concurrency-safe and inherited by tool worker threads.
# We deliberately do NOT write os.environ["HERMES_SESSION_KEY"] here:
# os.environ is process-global, so concurrent gateway sessions (e.g.
# two Discord threads) would clobber each other's value, and a tool
# thread whose contextvar is unset would fall back to os.environ and
# read the wrong session key — misrouting command-approval prompts to
# the wrong thread (#24100). The non-gateway surfaces don't depend on
# this write: CLI and cron bind the session via contextvars
# (set_current_session_key / session context), and only the TUI
# slash-worker *subprocess* exports HERMES_SESSION_KEY (from its own
# --session-key argv, a separate process) — so removing this in-process
# gateway write does not affect any of them.
# session_key and recall_scope_key are propagated via contextvars in
# _set_session_env() (_SESSION_KEY / _RECALL_SCOPE_KEY) and session_key
# is also bound for approvals via set_current_session_key() below — both
# paths are concurrency-safe and inherited by tool worker threads.
#
# Deliberately do NOT write HERMES_SESSION_KEY or HERMES_RECALL_SCOPE_KEY
# to os.environ here: os.environ is process-global, so concurrent gateway
# sessions (e.g. two Discord threads) would clobber each other's values,
# and a tool thread whose contextvar is unset could read the wrong scope
# and route approvals/session_search to the wrong chat (#24100). CLI/cron
# bind session state outside this gateway path; the TUI slash-worker
# subprocess exports its own session env from argv in a separate process.

# Map platform enum to the platform hint key the agent understands.
# Platform.LOCAL ("local") maps to "cli"; others pass through as-is.
Expand Down Expand Up @@ -16355,6 +16366,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
# Refresh agent max_iterations from current config
# (cached agent may have been created with old config)
agent.max_iterations = max_iterations
agent._gateway_recall_scope_key = recall_scope_key
logger.debug("Reusing cached agent for session %s", session_key)

# Lock released — now schedule cleanup of any cross-process-evicted
Expand Down Expand Up @@ -16408,6 +16420,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
chat_type=source.chat_type,
thread_id=source.thread_id,
gateway_session_key=session_key,
gateway_recall_scope_key=recall_scope_key,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
Expand Down
101 changes: 99 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,33 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
)


def _session_scope_origin_json(source: Optional[SessionSource]) -> Dict[str, Any]:
"""Minimal stable routing metadata for debugging/backfill.

The opaque scope_key remains the search predicate. This JSON avoids
human-readable names/topics and one-off message IDs so session-list/debug
surfaces do not grow extra PII beyond routing identifiers.
"""
if source is None:
return {}
origin = source.to_dict()
return {
key: origin.get(key)
for key in (
"platform",
"chat_id",
"chat_type",
"thread_id",
"user_id",
"user_id_alt",
"chat_id_alt",
"guild_id",
"parent_chat_id",
"profile",
)
if origin.get(key) is not None
}


@dataclass
class SessionContext:
Expand All @@ -217,6 +244,7 @@ class SessionContext:

# Session metadata
session_key: str = ""
recall_scope_key: str = ""
session_id: str = ""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
Expand All @@ -230,6 +258,7 @@ def to_dict(self) -> Dict[str, Any]:
},
"shared_multi_user_session": self.shared_multi_user_session,
"session_key": self.session_key,
"recall_scope_key": self.recall_scope_key,
"session_id": self.session_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
Expand Down Expand Up @@ -779,6 +808,49 @@ def build_session_key(
return ":".join(key_parts)


def build_recall_scope_key(
source: SessionSource,
profile: Optional[str] = None,
) -> str:
"""Build the current-chat/thread recall scope key for ``session_search``.

This intentionally differs from :func:`build_session_key`: gateway agent
sessions may be isolated per user, but recall/search should cover the
current Discord/Telegram/etc. chat or thread as a whole. Therefore this key
never appends ``user_id`` for group/channel sessions.
"""
ns = _session_key_namespace(profile)
platform = source.platform.value

if source.chat_type == "dm":
dm_chat_id = source.chat_id
if source.platform == Platform.WHATSAPP:
dm_chat_id = canonical_whatsapp_identifier(source.chat_id)

key_parts = [ns, platform, "dm"]
if dm_chat_id:
key_parts.append(dm_chat_id)
else:
participant_id = source.user_id_alt or source.user_id
if participant_id and source.platform == Platform.WHATSAPP:
participant_id = (
canonical_whatsapp_identifier(str(participant_id))
or participant_id
)
if participant_id:
key_parts.append(str(participant_id))
if source.thread_id:
key_parts.append(str(source.thread_id))
return ":".join(key_parts)

key_parts = [ns, platform, source.chat_type]
if source.chat_id:
key_parts.append(source.chat_id)
if source.thread_id:
key_parts.append(str(source.thread_id))
return ":".join(key_parts)


class SessionStore:
"""
Manages session storage and retrieval.
Expand Down Expand Up @@ -965,6 +1037,13 @@ def _generate_session_key(self, source: SessionSource) -> str:
thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False),
profile=self._resolve_profile_for_key(source),
)

def _generate_recall_scope_key(self, source: SessionSource) -> str:
"""Generate the DB recall/search scope key for a source."""
return build_recall_scope_key(
source,
profile=self._resolve_profile_for_key(source),
)

def _is_session_expired(self, entry: SessionEntry) -> bool:
"""Check if a session has expired based on its reset policy.
Expand Down Expand Up @@ -1095,7 +1174,7 @@ def get_or_create_session(
# SQLite calls are made outside the lock to avoid holding it during I/O.
# All _entries / _loaded mutations are protected by self._lock.
db_end_session_id = None
db_create_kwargs = None
db_create_kwargs: Optional[Dict[str, Any]] = None

with self._lock:
self._ensure_loaded_locked()
Expand Down Expand Up @@ -1139,6 +1218,8 @@ def get_or_create_session(
auto_reset_reason = None
reset_had_activity = False

recall_scope_key = self._generate_recall_scope_key(source)

# Create new session
session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"

Expand All @@ -1162,6 +1243,8 @@ def get_or_create_session(
"session_id": session_id,
"source": source.platform.value,
"user_id": source.user_id,
"scope_key": recall_scope_key,
"origin_json": _session_scope_origin_json(source),
}

# SQLite operations outside the lock
Expand Down Expand Up @@ -1355,7 +1438,7 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
def reset_session(self, session_key: str, display_name: Optional[str] = None) -> Optional[SessionEntry]:
"""Force reset a session, creating a new session ID."""
db_end_session_id = None
db_create_kwargs = None
db_create_kwargs: Optional[Dict[str, Any]] = None
new_entry = None

with self._lock:
Expand Down Expand Up @@ -1384,10 +1467,13 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->

self._entries[session_key] = new_entry
self._save()
recall_scope_key = self._generate_recall_scope_key(old_entry.origin) if old_entry.origin else session_key
db_create_kwargs = {
"session_id": session_id,
"source": old_entry.platform.value if old_entry.platform else "unknown",
"user_id": old_entry.origin.user_id if old_entry.origin else None,
"scope_key": recall_scope_key,
"origin_json": _session_scope_origin_json(old_entry.origin),
}

if self._db and db_end_session_id:
Expand Down Expand Up @@ -1649,6 +1735,17 @@ def build_session_context(

if session_entry:
context.session_key = session_entry.session_key
_profile = None
if getattr(config, "multiplex_profiles", False):
if source.profile:
_profile = source.profile
else:
try:
from hermes_cli.profiles import get_active_profile_name
_profile = get_active_profile_name() or "default"
except Exception:
_profile = None
context.recall_scope_key = build_recall_scope_key(source, profile=_profile)
context.session_id = session_entry.session_id
context.created_at = session_entry.created_at
context.updated_at = session_entry.updated_at
Expand Down
Loading