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
66 changes: 65 additions & 1 deletion gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pathlib import Path
from datetime import datetime, timedelta
from dataclasses import dataclass, field, replace
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, cast

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1211,6 +1211,33 @@ def build_session_key(
return ":".join(str(part) for part in key_parts)


def is_internal_subagent_row(row: Optional[Dict[str, Any]]) -> bool:
"""True when a sessions row is a delegate/subagent execution transcript.

Subagent sessions are internal execution records, never conversations a
human can address: ``delegate_tool`` creates them with
``platform="subagent"`` and stamps the durable
``model_config._delegate_from`` marker (#92859). Either signal alone is
enough — the marker survives a later ``record_gateway_session_peer``
overwriting ``source`` with the platform name, which is exactly what a
hijacked child row looks like after the fact.
"""
if not row:
return False
if str(row.get("source") or "") == "subagent":
return True
raw = row.get("model_config")
if not raw:
return False
try:
cfg = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, ValueError):
return False
if not isinstance(cfg, dict):
return False
return bool(str(cfg.get("_delegate_from") or "").strip())


class _SessionFlight:
def __init__(self) -> None:
self.event = threading.Event()
Expand Down Expand Up @@ -3557,10 +3584,47 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S
generating a fresh session ID, re-uses ``target_session_id`` so the
old transcript is loaded on the next message. If the target session was
previously ended, re-open it so gateway resume semantics match the CLI.

Refuses outright when ``target_session_id`` is a delegate/subagent
execution transcript (#92859). Those rows are internal — a human
cannot address one — so binding a platform routing key to one both
hijacks the chat (the user's next message lands in a leaf subagent
with no conversation context) and ends the real conversation at the
un-resurrectable ``session_switch`` boundary, which then makes the
delegation's own completion undeliverable. This is the sink for every
caller (async-completion pinning, /resume, CLI handoff, Telegram topic
rebinding), so no future call site can reintroduce the hijack.
"""
db_end_session_id = None
new_entry = None

if self._db is not None and target_session_id:
db = cast(Any, self._db)
try:
target_row = db.get_session(target_session_id)
except Exception:
# Fail OPEN: a SQLite hiccup must not break /resume or CLI
# handoff for every user. But this is precisely the shape that
# silently reintroduces #92859 — the lookup fails, the row
# reads as non-subagent, and the bind proceeds — so it is
# logged at warning, not debug, to leave a trace in the record
# when a hijack slips through this path.
logger.warning(
"switch_session subagent pre-check failed for %s; "
"allowing the bind (fail-open). A delegate row could be "
"bound to routing key %s if this recurs (#92859).",
target_session_id, session_key, exc_info=True,
)
target_row = None
if is_internal_subagent_row(target_row):
logger.warning(
"Refusing to bind gateway routing key %s to delegate "
"subagent session %s: subagent transcripts are internal "
"and are never route owners (#92859).",
session_key, target_session_id,
)
return None

with self._lock:
self._ensure_loaded_locked()

Expand Down
19 changes: 19 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
AsyncSessionStore,
SessionSource,
build_session_key,
is_internal_subagent_row,
is_shared_multi_user_session,
)
from hermes_cli.config import atomic_config_write, cfg_get, clear_model_endpoint_credentials
Expand Down Expand Up @@ -5194,6 +5195,24 @@ async def _list_titled_sessions() -> list[dict]:
# Clear any running agent for this session key
self._release_running_agent_state(session_key)

# A delegate/subagent transcript is an internal execution record, not a
# conversation (#92859). switch_session() refuses it outright, which
# would surface here as the generic "Failed to switch session." Detect
# it first so an explicit `/resume <subagent id>` explains itself
# instead of looking like a transient failure. Mirrors the guard's own
# fail-open posture: if the row can't be read, fall through and let
# switch_session decide.
if self._session_db:
try:
target_row = await self._session_db.get_session(target_id)
except Exception:
logger.debug(
"resume subagent pre-check failed for %s", target_id, exc_info=True
)
target_row = None
if is_internal_subagent_row(target_row):
return t("gateway.resume.blocked_subagent", name=name)

# Switch the session entry to point at the old session
new_entry = await self.async_session_store.switch_session(session_key, target_id)
if not new_entry:
Expand Down
3 changes: 3 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
_FTS_CJK_TRIGGERS,
_FTS_TRIGGERS,
_LISTABLE_CHILD_SQL,
_NOT_SUBAGENT_ROW_SQL,
_PREVIEW_ELIGIBLE_SQL,
_PREVIEW_RAW_SELECT,
_RECOVERABLE_END_REASONS,
Expand Down Expand Up @@ -6600,6 +6601,7 @@ def find_latest_gateway_session_for_peer(
WHERE s.session_key = ?
AND s.source = ?
AND (s.ended_at IS NULL OR s.end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))
AND {_NOT_SUBAGENT_ROW_SQL.format(a='s')}
AND NOT EXISTS (
SELECT 1 FROM sessions b
WHERE b.session_key = s.session_key
Expand Down Expand Up @@ -6639,6 +6641,7 @@ def find_latest_gateway_session_for_peer(
AND COALESCE(s.chat_type, '') = COALESCE(?, '')
AND COALESCE(s.thread_id, '') = COALESCE(?, '')
AND (s.ended_at IS NULL OR s.end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))
AND {_NOT_SUBAGENT_ROW_SQL.format(a='s')}
AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
))
Expand Down
25 changes: 25 additions & 0 deletions hermes_state_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,31 @@ def _shape_preview(raw: Any) -> str:
)


# A delegate/subagent execution transcript is never a gateway route owner
# (#92859). ``delegate_tool`` creates these rows with ``source='subagent'``
# and the durable ``model_config._delegate_from`` marker; either signal is
# enough, because a row that already got hijacked has had its ``source``
# overwritten with the platform name by ``record_gateway_session_peer``.
# Interpolated (not bound) so it can be embedded in the peer-recovery
# queries alongside the other f-string predicates in this module.
#
# ``NULLIF(TRIM(...), '')`` keeps this predicate equivalent to the Python
# detector ``gateway.session.is_internal_subagent_row``, which treats a blank
# marker as absent via ``str(...).strip()``. TRIM matches ``.strip()`` for the
# whitespace-only case and NULLIF collapses the empty result to NULL. Without
# both, a row carrying ``_delegate_from: ""`` (or ``" "``) would be refused a
# route bind by the Python guard yet still be returned by restart recovery
# here — one invariant, two answers. Nothing writes a blank marker today; this
# keeps the halves from drifting if anything ever does.
_NOT_SUBAGENT_ROW_SQL = (
"(COALESCE({a}.source, '') != 'subagent'"
" AND NULLIF(TRIM("
"COALESCE(json_extract(COALESCE({a}.model_config, '{{}}'),"
" '$._delegate_from'), '')), '')"
" IS NULL)"
)


_RESET_END_REASONS = (
"session_reset",
# switch_session() never creates a child row, but pre-marker DBs can hold
Expand Down
1 change: 1 addition & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ gateway:
not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
already_on: "📌 Already on session **{name}**."
switch_failed: "Failed to switch session."
blocked_subagent: "⚠️ /resume blocked: '**{name}**' is an internal subagent transcript, not a conversation. Delegate runs are execution records owned by the session that spawned them — resume that session instead."
resumed_one: "↻ Resumed session **{title}** ({count} message). Conversation restored."
resumed_many: "↻ Resumed session **{title}** ({count} messages). Conversation restored."
resumed_no_count: "↻ Resumed session **{title}**. Conversation restored."
Expand Down
Loading