mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
A messaging reply's adapter task (_process_message_background) pops and runs the post-delivery callbacks a turn registers on its delivery adapter: the background-review release, temp-bubble cleanup and the deferred /goal and /loop verdict notices. Desktop/TUI/CLI turns run through the session authority's drain (execute_admission) with no such task, so on LocalSessionAdapter nothing ever ran or popped them: - the "Goal achieved"/"Goal continuing" verdict the gateway judged never reached a local viewer; - the per-route slot kept every local session's last TurnContext alive until restart, and with it that turn's AIAgent (after the agent cache evicted it), history and result messages. execute_admission now moves a non-native turn's callback off the adapter slot onto its admission, and the drain runs it once the turn settled and its reply went out (after deliver_settled), or on the unsettled and cancelled exits. Native messaging turns keep running them in their adapter task. Sibling per-session state the local adapter/authority never shrank on delete: retire_live_sessions now drops the deleted session's LocalSessionAdapter source, frozen policy and post-delivery slot and its launch_sources entry; a detached conversation that returns to its chat drops its detached admission source.
304 lines
16 KiB
Python
304 lines
16 KiB
Python
"""A messaging conversation its chat moved past stays usable from the apps (Teknium's ruling).
|
|
|
|
When a messaging chat names another conversation (``/new``, ``/resume``, ``/branch``, a handoff,
|
|
its next inbound naming another one), a Desktop / Webapp / mobile view still open on the previous
|
|
conversation keeps working: its turns, Stop and controls act on that conversation alone, and its
|
|
replies reach its viewers only, never the chat, which now gets only its new conversation's replies.
|
|
|
|
``detach`` moves the live session off the chat's route onto a key that names the conversation
|
|
(``agent:<ns>:detached:<session id>``) and pins that key on a copy of its source
|
|
(``SessionSource.detached_route``). From there the turn runs on the ordinary messaging path:
|
|
|
|
* ``build_session_key`` answers the pinned key, so the runner's per-route state (running slot,
|
|
agent cache, busy guard, the agent a Stop reaches) is the conversation's own, never the chat's;
|
|
* ``SessionStore.get_or_create_session`` resolves the key to the conversation itself
|
|
(``detached_entry``), and never creates or persists a route for it;
|
|
* delivery goes through the profile's ``LocalSessionAdapter`` (gateway/session_local.py), as a local
|
|
session's does: its sends publish nothing, viewers read the canonical event stream.
|
|
|
|
The key carries no platform slot, so a reader that parses a chat out of a session key
|
|
(``gateway.run._parse_session_key``) finds none and delivers nowhere. Its turns bind the session
|
|
variables a Desktop session's turn binds (``session_env_source``), so a reminder, a kanban subscription
|
|
or a ``send_message`` it starts names the app, never the chat. The chat naming the conversation again
|
|
(``/resume``) moves it back onto the chat's route (``SessionAuthority.name_lane``).
|
|
|
|
Every route change reaches ``name_lane`` from one place: the session store announces each conversation a
|
|
route comes to name (``SessionStore.route_named``: ``/new``, ``/resume``, ``/branch``, ``/handoff``,
|
|
the context-exhaustion auto-reset, a reset policy, a topic binding heal), wired by ``watch_routes``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
|
|
_SLOT = 'detached'
|
|
|
|
|
|
def detached_route_of(source) -> str | None:
|
|
"""*source*'s pinned detached key; None for any other source (duck-typed test sources too)."""
|
|
route = getattr(source, 'detached_route', None)
|
|
return route if isinstance(route, str) else None
|
|
|
|
|
|
def detached_key(route: str, session_id: str) -> str:
|
|
"""The detached key of *session_id*, in the profile namespace of its chat's *route*."""
|
|
parts = route.split(':')
|
|
namespace = ':'.join(parts[:2]) if len(parts) > 1 and parts[0] == 'agent' else 'agent:main'
|
|
return f'{namespace}:{_SLOT}:{session_id}'
|
|
|
|
|
|
def detached_session_id(key: str) -> str | None:
|
|
parts = key.split(':')
|
|
return parts[3] if len(parts) == 4 and parts[0] == 'agent' and parts[2] == _SLOT else None
|
|
|
|
|
|
def detach(authority, live, session_id) -> None:
|
|
"""Move *live* (a messaging conversation its chat no longer names) off the chat's route. A
|
|
history mount (no source) has no route to leave, and a detached one has left it already."""
|
|
if live.source is None or detached_route_of(live.source):
|
|
return
|
|
from gateway.session_identity import replace_source
|
|
from gateway.session_local_recovery import local_adapter
|
|
adapter = local_adapter(authority) # its delivery; a messaging-only gateway has none yet
|
|
live.route = detached_key(live.route, session_id)
|
|
# No reply anchor: its replies reach the apps, and a background result's event is this very source.
|
|
live.source = replace_source(live.source, detached_route=live.route, message_id=None)
|
|
adapter.register_source(live.source) # what admits its background results (``authorize_source``)
|
|
|
|
|
|
def lane_source(source):
|
|
"""*source* as its chat's own, a detached copy's pin dropped: what a conversation that takes the
|
|
chat's route over starts from (the route moved first, so the one it replaced is detached by now)."""
|
|
if detached_route_of(source) is None:
|
|
return source
|
|
from gateway.session_identity import replace_source
|
|
return replace_source(source, detached_route=None)
|
|
|
|
|
|
def forget_detached(authority, key) -> None:
|
|
"""*key* names no live conversation any more (it came back onto its chat, or retired): its
|
|
cached agent commits and goes, as at any boundary (a later idle eviction would commit it while the
|
|
conversation runs elsewhere), and the store forgets the ids it owned (``detached_entry``)."""
|
|
runner = authority.runner
|
|
evict = getattr(runner, '_evict_cached_agent_at_boundary', None)
|
|
if evict is not None:
|
|
evict(key)
|
|
drop_owner_hints(runner.session_store, key)
|
|
from gateway.config import Platform
|
|
from gateway.session_local_recovery import local_adapter_map
|
|
local = local_adapter_map(authority).get(Platform.LOCAL)
|
|
if local is not None:
|
|
local.sources.pop(key, None) # what admitted the detached copy's background results
|
|
|
|
|
|
def drop_owner_hints(store, key) -> None:
|
|
hints = getattr(store, '_session_owner_hints', None) or {}
|
|
for session_id in [sid for sid, owner in list(hints.items()) if owner == key]:
|
|
hints.pop(session_id, None)
|
|
|
|
|
|
def running_key(runner, route, session_id) -> str:
|
|
"""The key a turn of *session_id* runs under: its detached key while a turn that started there
|
|
still runs (the chat took the conversation back meanwhile), so a Stop from the chat or the app
|
|
reaches it; else *route*."""
|
|
key = detached_key(route, session_id)
|
|
return key if key in (getattr(runner, '_running_agents', None) or {}) else route
|
|
|
|
|
|
def chat_running_key(runner, route, entry) -> str:
|
|
"""``running_key`` for the conversation the chat's *route* names (*entry*, its routing entry)."""
|
|
from gateway.session_authorities import active_authority
|
|
authority = active_authority(runner)
|
|
return route if authority is None else running_key(runner, route, authority.logical_owner(entry.session_id))
|
|
|
|
|
|
def session_env_source(runner, source):
|
|
"""The source a turn's session variables (``HERMES_SESSION_PLATFORM``, ``_CHAT_ID``, ...) name.
|
|
A conversation its chat moved past binds the local view a Desktop session's turn binds
|
|
(``local_source``): whatever it schedules for "here" reaches the apps, never the chat."""
|
|
key = detached_route_of(source)
|
|
if key is None:
|
|
return source
|
|
from gateway.session_authorities import active_authority
|
|
from gateway.session_local_recovery import local_source
|
|
return local_source(active_authority(runner), detached_session_id(key), source.user_id)
|
|
|
|
|
|
def _route_authority(runner, store, route):
|
|
"""The authority owning *route*: its key names the profile, whatever scope the writer runs in."""
|
|
from gateway.session_authorities import authority_for_home
|
|
from hermes_constants import hermes_home_key
|
|
registry = getattr(runner, 'session_authorities', None)
|
|
if store._named_profile_for_key(route) is None: # the launch profile's own key
|
|
if registry is None:
|
|
return getattr(runner, 'session_authority', None)
|
|
return next((a for a in registry.serving() if hermes_home_key(a.profile_id) == registry.launch_key), None)
|
|
home = store._profile_home_for_key(route)
|
|
return authority_for_home(runner, home) if home is not None else None
|
|
|
|
|
|
def watch_routes(runner, store) -> None:
|
|
"""Wire *store*'s route announcements to the owning authority's ``name_lane``. A store write off
|
|
the owner loop (a worker thread) hands the move to the loop, ahead of the writer's own resumption,
|
|
so nothing on the loop sees the new route before the conversation it replaced has left it."""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def route_named(route, session_id, origin):
|
|
authority = _route_authority(runner, store, route)
|
|
if authority is None or origin is None:
|
|
return
|
|
|
|
def name():
|
|
# Writers announce outside the store lock, so announcements can land out of order: one
|
|
# the store has since moved past (a worker's /resume overtaken by a /new) would hand the
|
|
# chat back a conversation it left; the newer write announces itself. Compared by logical
|
|
# owner: a compression repoint since (unannounced) is still the same conversation.
|
|
current = store.lookup_by_session_key(route)
|
|
owner = authority.logical_owner(session_id)
|
|
if current is None or authority.logical_owner(current.session_id) != owner:
|
|
return
|
|
authority.name_lane(route, owner, origin)
|
|
try:
|
|
on_loop = asyncio.get_running_loop() is loop
|
|
except RuntimeError:
|
|
on_loop = False
|
|
if on_loop:
|
|
name()
|
|
elif not loop.is_closed():
|
|
loop.call_soon_threadsafe(name)
|
|
store.route_named = route_named
|
|
|
|
|
|
def recover_detached_sessions(authority, pending):
|
|
"""Boot: schedule the queued input of conversations their chats moved past (an app prompt
|
|
accepted during a restart's after-turn wait). The routing index names only each chat's current
|
|
conversation, so native recovery never binds these, and no chat message will wake them."""
|
|
from gateway.session_contract import SessionRef
|
|
from gateway.session_route_recovery import restore_routed_session
|
|
scheduled = []
|
|
from hermes_state_runtime import list_session_admissions
|
|
for session_id in sorted(pending):
|
|
if session_id in authority.sessions or not any( # a lost (unknown) turn waits for /discard
|
|
row['status'] == 'queued' for row in list_session_admissions(authority.db, session_id=session_id)):
|
|
continue
|
|
if restore_routed_session(authority, session_id) is None:
|
|
continue
|
|
if authority.sessions[session_id].detached:
|
|
authority._schedule(SessionRef(authority.profile_id, session_id))
|
|
scheduled.append(session_id)
|
|
return scheduled
|
|
|
|
|
|
def detached_live(authority, route):
|
|
"""``(ref, live)`` of the conversation still live on detached key *route*, else None."""
|
|
from gateway.session_contract import SessionRef
|
|
session_id = detached_session_id(route)
|
|
live = authority.sessions.get(session_id) if session_id else None
|
|
if live is None or live.route != route:
|
|
return None
|
|
return SessionRef(authority.profile_id, session_id), live
|
|
|
|
|
|
def own_detached_key(key, session_id) -> bool:
|
|
"""*key* is *session_id*'s own detached key (the route it was on while its chat was elsewhere)."""
|
|
return isinstance(key, str) and detached_session_id(key) == session_id
|
|
|
|
|
|
async def follow_detached_pin(runner, evt):
|
|
"""*evt* (a background result) on its pinned conversation's detached key, else *evt* itself.
|
|
|
|
The chat's turn on a conversation started it, and the chat has moved on since, but the
|
|
conversation is still open: the app used it after the chat's ``/new`` (a row the ``/new`` left
|
|
ended stays a boundary, and its result is dropped once). The result lands in that conversation,
|
|
for its app viewers, never in the chat's current one, and is never refused there on every retry
|
|
(#141562). A conversation not mounted (a restart) mounts off the chat first."""
|
|
key = str(evt.get('session_key') or '')
|
|
pinned = str(evt.get('parent_session_id') or '').strip()
|
|
from gateway.session_authorities import all_authorities
|
|
if not key or not all_authorities(runner):
|
|
return evt
|
|
if (detached := detached_session_id(key)) is not None:
|
|
return _follow_detached_key(runner, evt, key, detached)
|
|
if not pinned:
|
|
return evt
|
|
store = runner.session_store
|
|
authority = _route_authority(runner, store, key)
|
|
entry = store.lookup_by_session_key(key) if authority is not None else None
|
|
if authority is None or entry is None:
|
|
return evt
|
|
verdict, _owner, target = await runner._classify_completion_owner(pinned, key)
|
|
session_id = authority.logical_owner(target) if verdict == 'deliver' else None
|
|
if session_id is None or session_id == authority.logical_owner(entry.session_id):
|
|
return evt
|
|
row = authority.db.get_session(target)
|
|
if row is None or row.get('ended_at'):
|
|
return evt
|
|
live = _mounted(authority, session_id)
|
|
if live is None or live.route != detached_key(key, session_id):
|
|
return evt
|
|
return {**evt, 'session_key': live.route}
|
|
|
|
|
|
def _follow_detached_key(runner, evt, key, session_id):
|
|
"""A result of a turn that ran on detached *key*: on the chat's route once the chat names the
|
|
conversation again (its ``/resume``), as that conversation's own result there."""
|
|
authority = _route_authority(runner, runner.session_store, key)
|
|
live = _mounted(authority, session_id) if authority is not None else None
|
|
if live is None or live.route == key:
|
|
return evt
|
|
return {**evt, 'session_key': live.route}
|
|
|
|
|
|
def _mounted(authority, session_id):
|
|
"""*session_id*'s live session, mounted as its route would after a restart (off the chat when the
|
|
chat names another conversation); None when it cannot be mounted now."""
|
|
if session_id not in authority.sessions:
|
|
from gateway.session_authorities import owner_scope
|
|
from gateway.session_route_recovery import restore_routed_session
|
|
from hermes_state_runtime import RuntimeStoreError
|
|
try:
|
|
with owner_scope(authority):
|
|
restore_routed_session(authority, session_id)
|
|
except RuntimeStoreError: # draining: the drain retries it
|
|
return None
|
|
return authority.sessions.get(session_id)
|
|
|
|
|
|
def detached_live_source(runner, key):
|
|
"""The source of the conversation still live on detached key *key*, else None. The key's
|
|
namespace names the owning profile: this runs before any profile scope is bound
|
|
(``_completion_event_scope`` derives the scope from it)."""
|
|
authority = _route_authority(runner, runner.session_store, key) if detached_session_id(key) else None
|
|
found = detached_live(authority, key) if authority is not None else None
|
|
return found[1].source if found is not None else None
|
|
|
|
|
|
def route_entry(authority, route):
|
|
"""The routing entry *route* names: the store's, or a detached key's live conversation (a
|
|
background result it started finds it there, never on the chat's route)."""
|
|
store = authority.runner.session_store
|
|
entry = store.lookup_by_session_key(route)
|
|
if entry is None and (found := detached_live(authority, route)) is not None:
|
|
return detached_entry(store, found[1].source)
|
|
return entry
|
|
|
|
|
|
def detached_entry(store, source):
|
|
"""The routing entry a detached *source* resolves to: its own conversation, at the compression
|
|
tip a turn writes to. Never stored: the chat's route stays the only one this conversation has."""
|
|
from gateway.session import SessionEntry
|
|
key = source.detached_route
|
|
session_id = str(detached_session_id(key))
|
|
db = store._db_for_key(key)
|
|
row = db.get_session(session_id) if db is not None else None
|
|
tip = (db.get_compression_tip(session_id) if db is not None else None) or session_id
|
|
# No route names it, so its by-id reads and writes (history, transcript, compression) find their
|
|
# profile's store through the key; ``forget_detached`` drops these.
|
|
hints = store._lazy('_session_owner_hints', dict)
|
|
hints[session_id] = hints[tip] = key
|
|
now = datetime.now()
|
|
# Never a new conversation (that would fire session:start and first-contact notes).
|
|
started = datetime.fromtimestamp(row['started_at']) if row and row.get('started_at') else now - timedelta(seconds=1)
|
|
return SessionEntry(session_key=key, session_id=tip, created_at=started, updated_at=now, origin=source,
|
|
platform=source.platform, chat_type=source.chat_type)
|