Files
hermes-agent/gateway/session_context.py
Teknium 3c7069bdcb refactor(gateway): dead-code removal, helper unification, defensive-layer collapse and rationale-preserving comment compaction across 40 modules
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control,
hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__,
relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state,
streaming_tts_consumer, turn_lease and small modules.

- HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table
- WebSocketRelayTransport._handle_frame -> frame-handler table
- GatewayAuthorizationMixin: unified adapter setting/flag/extra readers
- dead symbols removed (verified zero references): RoomLinkProbe/select_room_link,
  relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead,
  BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/
  _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env,
  _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind,
  _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests.
- lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
2026-09-02 13:30:50 -07:00

364 lines
15 KiB
Python

"""Session-scoped context variables for the Hermes gateway.
Replaces the old ``os.environ``-based session state (``HERMES_SESSION_*``)
with ``contextvars.ContextVar``. The gateway processes messages concurrently
via asyncio; ``os.environ`` is process-global, so message B silently
overwrote message A's thread id before A's agent finished and notifications
routed to the wrong thread. ContextVar values are task-local (inherited by
``run_in_executor`` threads), so concurrent messages never interfere.
``get_session_env(name, default="")`` is a drop-in for
``os.getenv("HERMES_SESSION_*", default)`` at existing tool call sites.
"""
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator
# Distinguishes "never set in this context" (fall back to os.environ for
# CLI/cron compat) from "explicitly set to empty" by clear_session_vars (no fallback).
_UNSET: Any = object()
# Process-level monotonic latch: has any code in this process bound a session via
# set_session_vars()? Concurrent multi-session hosts (gateway, ACP, API server,
# TUI, cron) do; a pure single-process CLI/one-shot does not. The subprocess-env
# bridge (tools/environments/local.py) reads this to pick its leak policy: when
# engaged, the ContextVars are authoritative and an _UNSET var means "no session
# bound in THIS task", so the last-writer-wins os.environ mirror must NOT be
# inherited by a child process. When never engaged, the os.environ fallback is
# preserved (no concurrency to leak across).
_session_context_engaged: bool = False
def session_context_engaged() -> bool:
"""True if any session has been bound via set_session_vars in this process."""
return _session_context_engaged
def _var(name: str) -> ContextVar:
return ContextVar(name, default=_UNSET)
# --- Per-task session variables --------------------------------------------
_SESSION_PLATFORM = _var("HERMES_SESSION_PLATFORM")
_SESSION_SOURCE = _var("HERMES_SESSION_SOURCE")
_SESSION_CHAT_ID = _var("HERMES_SESSION_CHAT_ID")
_SESSION_CHAT_TYPE = _var("HERMES_SESSION_CHAT_TYPE")
_SESSION_CHAT_NAME = _var("HERMES_SESSION_CHAT_NAME")
_SESSION_THREAD_ID = _var("HERMES_SESSION_THREAD_ID")
_SESSION_USER_ID = _var("HERMES_SESSION_USER_ID")
_SESSION_USER_ID_ALT = _var("HERMES_SESSION_USER_ID_ALT")
_SESSION_USER_NAME = _var("HERMES_SESSION_USER_NAME")
# Platform-neutral scope discriminator (Discord guild / Slack workspace / Matrix
# server). Captured at bind time so async producers (delegate_task
# background=True, terminal watchers) can persist a completion's full routing
# origin: a relay connector's fail-closed egress guard needs scope_id (or a user
# binding) to resolve the tenant for a scoped reply after a restart.
_SESSION_SCOPE_ID = _var("HERMES_SESSION_SCOPE_ID")
_SESSION_KEY = _var("HERMES_SESSION_KEY")
_SESSION_ID = _var("HERMES_SESSION_ID")
# In-process UI tab/window id for multi-session desktop/TUI hosts — deliberately
# separate from the durable HERMES_SESSION_ID. Background completions use it as
# a precise return address so a stale/rotated durable key cannot be consumed by
# whichever desktop poller wakes first.
_SESSION_UI_SESSION_ID = _var("HERMES_UI_SESSION_ID")
# Triggering message id: reply anchor so background notifications stay inside
# the originating Telegram private-chat topic (routes only with thread id + anchor).
_SESSION_MESSAGE_ID = _var("HERMES_SESSION_MESSAGE_ID")
_SESSION_PROFILE = _var("HERMES_SESSION_PROFILE")
_BROWSER_CONTROL_PRINCIPAL = _var("HERMES_BROWSER_CONTROL_PRINCIPAL")
_BROWSER_CONTROL_TRANSPORT_FAMILY = _var("HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY")
# Per-session cron marker, tri-state: _UNSET keeps the legacy env fallback for
# CLI/tests; "1" marks cron; "" explicitly marks non-cron and masks leaked env.
_CRON_SESSION = _var("HERMES_CRON_SESSION")
# Whether this session's channel can route an ASYNC completion back to the agent
# AFTER the turn ends (wake a fresh turn). True for long-lived CLI sessions and
# real gateway platforms (persistent outbound channel + watcher/drain loops);
# False for finite runtimes that may exit before a detached completion returns
# (stateless API-server requests, dispatcher-spawned Kanban workers). Tools that
# promise async delivery (terminal notify_on_complete / watch_patterns,
# delegate_task background=True) read ``async_delivery_supported()`` and refuse a
# promise the channel can't keep. Default _UNSET => supported, so the CLI (never
# sets a platform) and contextvar-unaware paths keep working; stateless adapters
# opt OUT via ``supports_async_delivery = False`` on the adapter class, which the
# gateway propagates here at session-bind time.
_SESSION_ASYNC_DELIVERY = _var("HERMES_SESSION_ASYNC_DELIVERY")
# Cron auto-delivery vars — set per-job in run_job() so concurrent jobs don't
# clobber each other's delivery targets.
_CRON_AUTO_DELIVER_PLATFORM = _var("HERMES_CRON_AUTO_DELIVER_PLATFORM")
_CRON_AUTO_DELIVER_CHAT_ID = _var("HERMES_CRON_AUTO_DELIVER_CHAT_ID")
_CRON_AUTO_DELIVER_THREAD_ID = _var("HERMES_CRON_AUTO_DELIVER_THREAD_ID")
# Vars bound by set_session_vars / cleared to "" by clear_session_vars, in order.
_SESSION_VARS = (
_SESSION_PLATFORM,
_SESSION_SOURCE,
_SESSION_CHAT_ID,
_SESSION_CHAT_TYPE,
_SESSION_CHAT_NAME,
_SESSION_THREAD_ID,
_SESSION_USER_ID,
_SESSION_USER_ID_ALT,
_SESSION_USER_NAME,
_SESSION_SCOPE_ID,
_SESSION_KEY,
_SESSION_ID,
_SESSION_UI_SESSION_ID,
_SESSION_MESSAGE_ID,
_SESSION_PROFILE,
_BROWSER_CONTROL_PRINCIPAL,
_BROWSER_CONTROL_TRANSPORT_FAMILY,
_CRON_SESSION,
)
# Legacy env-var name -> ContextVar, for get_session_env. _SESSION_ASYNC_DELIVERY
# is deliberately absent: it is a bool capability read via async_delivery_supported.
_VAR_MAP = {
var.name: var
for var in (*_SESSION_VARS, _CRON_AUTO_DELIVER_PLATFORM, _CRON_AUTO_DELIVER_CHAT_ID, _CRON_AUTO_DELIVER_THREAD_ID)
}
def _clear_session_cwd() -> None:
try:
from agent.runtime_cwd import clear_session_cwd
clear_session_cwd()
except Exception:
pass
def set_current_session_id(session_id: str) -> None:
"""Synchronize ``HERMES_SESSION_ID`` across ContextVar and ``os.environ``.
Long-lived single-process entrypoints (CLI) rotate sessions via /new,
/resume, /branch, or compression splits without rebuilding the agent; tools
read ``get_session_env("HERMES_SESSION_ID")`` with an os.environ fallback,
so both stores must move together.
Delegated subagent children are the exception: they are built in the parent
process inside ``delegated_child_context()`` and their ``AIAgent.__init__``
calls this helper. Writing the child's id to process-global ``os.environ``
would clobber the parent's id for the rest of the process, so only the
task-local ContextVar write happens for them. Root agents keep both paths.
"""
import os
_SESSION_ID.set(session_id)
try:
from agent.delegation_context import is_delegated_child_context
if is_delegated_child_context():
return
except Exception:
pass
os.environ["HERMES_SESSION_ID"] = session_id
@contextmanager
def scoped_current_session_id(session_id: str | None = None) -> Iterator[None]:
"""Bind a task-local session id and restore the prior value on exit.
``session_id=None`` makes this a pure save/restore boundary around code that
may call :func:`set_current_session_id` itself (delegated ``AIAgent``
construction). Never mutates ``os.environ``.
"""
previous = _SESSION_ID.get()
if session_id is not None:
_SESSION_ID.set(session_id)
try:
yield
finally:
_SESSION_ID.set(previous)
def set_session_vars(
platform: str = "",
source: str = "",
chat_id: str = "",
chat_type: str = "",
chat_name: str = "",
thread_id: str = "",
user_id: str = "",
user_id_alt: str = "",
user_name: str = "",
scope_id: str = "",
session_key: str = "",
session_id: str = "",
message_id: str = "",
profile: str = "",
browser_control_principal: str = "",
browser_control_transport_family: str = "",
cwd: str = "",
async_delivery: bool = True,
ui_session_id: str = "",
cron_session: Any = _UNSET,
) -> list:
"""Set all session context variables and return reset tokens.
Call ``clear_session_vars(tokens)`` in a ``finally`` when the handler exits.
These helpers are not nestable: clearing resets every var to ``""`` rather
than restoring prior values, and the tokens are accepted only for API compat.
``cwd`` pins the logical working directory. ``async_delivery`` declares
whether the channel can route a background completion back after the turn
(stateless adapters such as the API server pass ``False``). ``cron_session``
is tri-state; see ``_CRON_SESSION``.
"""
# Latch the process as engaged — see _session_context_engaged.
global _session_context_engaged
_session_context_engaged = True
values = (
platform, source, chat_id, chat_type, chat_name, thread_id, user_id,
user_id_alt, user_name, scope_id, session_key, session_id, ui_session_id,
message_id, profile, browser_control_principal,
browser_control_transport_family, cron_session,
)
tokens = [var.set(value) for var, value in zip(_SESSION_VARS, values)]
tokens.append(_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)))
try:
from agent.runtime_cwd import set_session_cwd
set_session_cwd(cwd)
except Exception:
pass
return tokens
def clear_session_vars(tokens: list) -> None:
"""Mark session context variables as explicitly cleared.
Sets every var to ``""`` (not ``var.reset(token)``) so ``get_session_env``
returns empty instead of falling back to stale ``os.environ`` values while
staying distinguishable from "never set" (``_UNSET``). Async-delivery is
reset to ``_UNSET`` rather than a falsy value: a cleared context must fall
back to default-supported, not look like an opted-out stateless adapter.
"""
for var in _SESSION_VARS:
var.set("")
_SESSION_ASYNC_DELIVERY.set(_UNSET)
_clear_session_cwd()
def reset_session_vars() -> None:
"""Reset every session context variable to ``_UNSET`` for THIS context.
Unlike :func:`clear_session_vars` (``""`` = "explicitly cleared", used when a
handler *finishes*), this restores "never bound here" — what a freshly
spawned task should look like *before* binding its own session.
Why: ``create_task`` snapshots the current context, so message B's task can
inherit message A's already-**set** vars. Until B binds its own session,
any subprocess it spawns reads A's identity through the subprocess-env
bridge — whose _UNSET-strip guard cannot help because the vars are set-to-A.
Calling this at the top of the per-message handler makes that window strip
safe (no session) instead of leaking the foreign one. See
tests/tools/test_local_env_session_leak.py and
tests/gateway/test_session_context_inheritance.py.
``_SESSION_ASYNC_DELIVERY`` is reset explicitly (it lives outside
``_VAR_MAP``): otherwise a task spawned from a context where a sibling
adapter bound ``async_delivery=False`` inherits that ``False`` through the
pre-bind window and misreports the new channel as unable to deliver.
"""
for var in _VAR_MAP.values():
var.set(_UNSET)
_SESSION_ASYNC_DELIVERY.set(_UNSET)
_clear_session_cwd()
def get_session_env(name: str, default: str = "") -> str:
"""Read a session context variable by its legacy ``HERMES_SESSION_*`` name.
Drop-in for ``os.getenv(name, default)``. Resolution: the ContextVar if it
was ever set in this context (even to ``""`` — no fallback); else
``os.environ`` (CLI, cron scheduler, tests that never bind); else *default*.
"""
import os
var = _VAR_MAP.get(name)
if var is not None and (value := var.get()) is not _UNSET:
return value
return os.getenv(name, default)
# Surfaces that are not a human chat channel. The gateway binds a platform value
# (``telegram``) to HERMES_SESSION_PLATFORM while the CLI/TUI/desktop bind
# HERMES_SESSION_SOURCE and leave platform empty, so both are consulted.
# ``local``, ``api_server``, ``webhook``, ``msgraph_webhook`` are real Platform
# values with no attachment channel behind them. Default-deny: an unrecognized
# identity counts as messaging so a new chat platform is never treated as a
# private surface before this set is updated. Mirrors LOCAL_SESSION_SOURCE_IDS
# in apps/desktop/src/lib/session-source.ts; keep roughly in sync.
NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"api_server",
"cli",
"codex",
"desktop",
"gateway",
"kanban",
"local",
"msgraph_webhook",
"tool",
"tui",
"webhook",
}
)
def session_is_messaging_surface() -> bool:
"""Whether this turn is delivered over a human messaging channel.
Decides "user is reading a chat message" vs "user is at a machine they own":
delivery tags, whether a file must land somewhere the gateway can send from,
whether narration reads as chat noise. Checks ``HERMES_PLATFORM``, then the
session platform, then the session source against
:data:`NON_MESSAGING_SESSION_SURFACES`.
"""
import os
platform = os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM", "")
source = get_session_env("HERMES_SESSION_SOURCE", "")
return any(
(ident := str(identity or "").strip().lower()) and ident not in NON_MESSAGING_SESSION_SURFACES
for identity in (platform, source)
)
def declare_stateless_channel() -> None:
"""Declare that this session cannot receive an async background completion.
Binds only the delivery capability. Use this instead of
``set_session_vars(async_delivery=False)`` on a pure single-process runner:
``set_session_vars`` also latches ``_session_context_engaged``, which flips
the subprocess env bridge to ContextVar-authoritative — a one-shot CLI must
not flip that latch as a side effect of declaring a capability. Callers that
build a full context (cron's ``run_job``) pass ``async_delivery=False``.
``delegate_task`` then falls through to its inline path so results return
within the turn instead of going to a channel that never delivers.
"""
_SESSION_ASYNC_DELIVERY.set(False)
def async_delivery_supported() -> bool:
"""Whether the current session can deliver a background completion later.
False for finite runtimes: sessions bound by a stateless channel (API
server, ``hermes -z``, cron — see :func:`declare_stateless_channel`) and
dispatcher-spawned Kanban workers (``HERMES_KANBAN_TASK``), which are
one-shot ``chat -q`` subprocesses whose parent disappears after the quiet
turn, so a later completion has no durable consumer. Gateway platforms,
the interactive CLI, and any path that never bound the var return True.
"""
import os
# Kanban worker: force tools onto their synchronous/polling fallbacks.
if os.environ.get("HERMES_KANBAN_TASK"):
return False
value = _SESSION_ASYNC_DELIVERY.get()
return True if value is _UNSET else bool(value)