mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(plugins): show on_session_finalize return messages to the user
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
This commit is contained in:
1 parent
3470e3ee2f
commit
dacb06ea93
17 files changed
+352
-52
No files matched your search
@@ -3329,6 +3329,7 @@ export interface SessionCloseParams {
|
||||
}
|
||||
export interface SessionCloseResult {
|
||||
closed: boolean
|
||||
messages?: string[]
|
||||
}
|
||||
export interface SessionBranchParams {
|
||||
session_id: string
|
||||
|
||||
@@ -29510,6 +29510,13 @@
|
||||
"closed": {
|
||||
"title": "Closed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"messages": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Messages",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -409,6 +409,7 @@ _cleanup_all_browsers = _lazy_shim("tools.browser_tool_lifecycle", "_emergency_c
|
||||
|
||||
_cleanup_done = False # _run_cleanup runs exactly once
|
||||
_cleanup_in_progress = False
|
||||
_session_end_messages: list[str] = [] # plugin on_session_finalize text awaiting the exit summary
|
||||
_cli_wake_owner = None
|
||||
# One-shot finalization runs before process cleanup (plugins see the boundary while the
|
||||
# agent is attached); atexit cleanup must not finalize those sessions again.
|
||||
@@ -520,7 +521,9 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
|
||||
if notify_session_finalize:
|
||||
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
|
||||
if _should_emit_cleanup_session_finalize(cleanup_session_id):
|
||||
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown")
|
||||
# Printed by _print_exit_summary, which clears the screen first.
|
||||
_session_end_messages.extend(
|
||||
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown"))
|
||||
try:
|
||||
_shutdown_agent_memory_provider(_active_agent_ref)
|
||||
except Exception as e:
|
||||
|
||||
+7
-27
@@ -28,6 +28,7 @@ from gateway.restart import (
|
||||
effective_stop_drain_timeout, effective_stop_watchdog_delay, resolve_cron_drain_budget
|
||||
)
|
||||
from gateway.run_common import _UNSET
|
||||
from gateway.run_shutdown_session_end import GatewaySessionEndMixin
|
||||
from gateway.shutdown_watchdog import arm_shutdown_watchdog, resolve_shutdown_watchdog_delay
|
||||
|
||||
# Log-record parity with the origin module.
|
||||
@@ -168,7 +169,7 @@ def _effective_watchdog_leash(runner: object) -> float:
|
||||
return effective_stop_watchdog_delay(runner, resolve_shutdown_watchdog_delay(effective_stop_drain_timeout(runner)))
|
||||
|
||||
|
||||
class GatewayShutdownMixin:
|
||||
class GatewayShutdownMixin(GatewaySessionEndMixin):
|
||||
"""Stop/drain/restart, scale-to-zero and active-work accounting methods for GatewayRunner."""
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -1173,10 +1174,14 @@ class GatewayShutdownMixin:
|
||||
self._flush_agent_transcript_at_shutdown(agent)
|
||||
# Off-loop + bounded: plugin on_session_finalize hooks can do arbitrary synchronous work
|
||||
# (e.g. a full-session trace export) — same hang class as the memory provider below.
|
||||
await self._finalize_session_off_loop(
|
||||
messages = await self._finalize_session_off_loop(
|
||||
session_id=getattr(agent, "session_id", None), platform="gateway", reason="shutdown",
|
||||
session_key=session_key,
|
||||
)
|
||||
try: # adapters are still connected here (teardown runs after this loop)
|
||||
await self._deliver_session_end_messages(messages, session_key=session_key)
|
||||
except Exception:
|
||||
logger.debug("Session-end plugin message delivery failed for %s", session_key, exc_info=True)
|
||||
# Off-loop + bounded: a wedged memory provider here used to hang the whole shutdown so
|
||||
# SIGTERM never completed.
|
||||
await self._cleanup_agent_resources_off_loop(agent, context="shutdown finalize", session_key=session_key)
|
||||
@@ -1224,31 +1229,6 @@ class GatewayShutdownMixin:
|
||||
tasks = self._deferred_agent_cleanup_tasks = set()
|
||||
self._track_task_in(tasks, asyncio.create_task(_cleanup_when_done()))
|
||||
|
||||
async def _finalize_session_off_loop(
|
||||
self, *, session_id: Any, platform: str, reason: str, session_key: Optional[str] = None, **extra: Any,
|
||||
) -> None:
|
||||
"""Run hermes_cli.lifecycle.finalize_session off-loop, bounded; on timeout the worker is left alone.
|
||||
``session_key`` lets an unscoped caller (shutdown) enter the owning profile's scope: plugin
|
||||
``on_session_finalize`` observers and the Relay coordinator (``current_profile_key``) resolve
|
||||
profile state at call time."""
|
||||
|
||||
def _call() -> None:
|
||||
from hermes_cli.lifecycle import finalize_session
|
||||
finalize_session(session_id=session_id, platform=platform, reason=reason, **extra)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._run_housekeeping_in_executor(self._run_release_in_profile_scope, _call, (), session_key),
|
||||
timeout=self._FINALIZE_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Session finalize hooks (%s, reason=%s) exceeded %ss; proceeding without blocking the event loop "
|
||||
"(the worker thread is left to finish on its own).", session_id, reason, self._FINALIZE_TIMEOUT_S,
|
||||
)
|
||||
except Exception as finalize_exc:
|
||||
logger.debug("Session finalize hooks (%s, reason=%s) failed: %s", session_id, reason, finalize_exc)
|
||||
|
||||
async def _cleanup_agent_resources_off_loop(
|
||||
self, agent: Any, *, context: str = "", session_key: Optional[str] = None,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Session finalize dispatch and delivery of plugin ``on_session_finalize`` messages for GatewayRunner.
|
||||
|
||||
Split out of ``gateway/run_shutdown.py``; bound onto ``GatewayRunner`` via ``GatewayShutdownMixin``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
logger = logging.getLogger("gateway.run")
|
||||
|
||||
|
||||
class GatewaySessionEndMixin:
|
||||
"""Bounded off-loop ``finalize_session`` plus out-of-band delivery of what plugins return."""
|
||||
|
||||
async def _deliver_session_end_messages(
|
||||
self, messages: List[str], *, source: Any = None, session_key: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Send plugin ``on_session_finalize`` text to the chat that owns the session — a plain adapter send,
|
||||
never an agent turn. No resolvable chat (or no adapter) → logged and dropped."""
|
||||
if not messages:
|
||||
return
|
||||
if source is None and session_key:
|
||||
target = await self._shutdown_notification_target(session_key)
|
||||
source = target[0] if target else None
|
||||
adapter = self._delivery_adapter_for(source) if source is not None else None
|
||||
if adapter is None:
|
||||
logger.info("Session-end plugin message(s) dropped: no chat for session %s", session_key)
|
||||
return
|
||||
metadata = self._thread_metadata_for_source(source)
|
||||
for message in messages:
|
||||
await self._send_notice_logged(
|
||||
adapter, str(source.chat_id), message, source.platform.value if source.platform else "",
|
||||
"Failed to send session-end plugin message to %s:%s: %s", metadata=metadata)
|
||||
|
||||
async def _finalize_session_off_loop(
|
||||
self, *, session_id: Any, platform: str, reason: str, session_key: Optional[str] = None, **extra: Any,
|
||||
) -> List[str]:
|
||||
"""Run hermes_cli.lifecycle.finalize_session off-loop, bounded; on timeout the worker is left alone.
|
||||
``session_key`` lets an unscoped caller (shutdown) enter the owning profile's scope: plugin
|
||||
``on_session_finalize`` observers and the Relay coordinator (``current_profile_key``) resolve
|
||||
profile state at call time. Returns the plugins' user-facing messages (empty on timeout/error)."""
|
||||
messages: List[str] = []
|
||||
|
||||
def _call() -> None:
|
||||
from hermes_cli.lifecycle import finalize_session, session_end_messages
|
||||
messages.extend(session_end_messages(
|
||||
finalize_session(session_id=session_id, platform=platform, reason=reason, **extra)))
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._run_housekeeping_in_executor(self._run_release_in_profile_scope, _call, (), session_key),
|
||||
timeout=self._FINALIZE_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Session finalize hooks (%s, reason=%s) exceeded %ss; proceeding without blocking the event loop "
|
||||
"(the worker thread is left to finish on its own).", session_id, reason, self._FINALIZE_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Session finalize hooks (%s, reason=%s) failed", session_id, reason, exc_info=True)
|
||||
return list(messages)
|
||||
@@ -145,9 +145,10 @@ class GatewaySessionCommandsMixin:
|
||||
arbitrarily), then session:end and session:reset."""
|
||||
platform_value = source.platform.value if source.platform else ""
|
||||
with contextlib.suppress(Exception):
|
||||
await self._finalize_session_off_loop(
|
||||
messages = await self._finalize_session_off_loop(
|
||||
session_id=old_sid, platform=platform_value, reason="new_session",
|
||||
old_session_id=old_sid, new_session_id=new_sid)
|
||||
await self._deliver_session_end_messages(messages, source=source, session_key=session_key)
|
||||
hook_payload = {"platform": platform_value, "user_id": source.user_id, "session_key": session_key}
|
||||
await self.hooks.emit("session:end", dict(hook_payload))
|
||||
await self.hooks.emit("session:reset", dict(hook_payload))
|
||||
|
||||
@@ -430,7 +430,10 @@ class CLISessionMixin:
|
||||
"platform": getattr(self, "platform", None) or "cli",
|
||||
"reason": "new_session" if event_type == "on_session_reset" else "session_boundary"}
|
||||
if event_type == "on_session_finalize":
|
||||
finalize_session(**context)
|
||||
from cli import _cli_visible_print
|
||||
from hermes_cli.lifecycle import session_end_messages
|
||||
for message in session_end_messages(finalize_session(**context)):
|
||||
_cli_visible_print(message)
|
||||
else:
|
||||
invoke_hook(event_type, **context)
|
||||
|
||||
@@ -1103,6 +1106,11 @@ class CLISessionMixin:
|
||||
# Honors NO_COLOR/dumb terminals by skipping silently when there's no real console.
|
||||
self._clear_terminal_on_exit()
|
||||
print()
|
||||
import cli as _cli_module
|
||||
# Plugin on_session_finalize messages from _run_cleanup: printed after the clear so they survive.
|
||||
for message in _cli_module._session_end_messages:
|
||||
print(message)
|
||||
_cli_module._session_end_messages.clear()
|
||||
msg_count = len(self.conversation_history)
|
||||
if not msg_count:
|
||||
try:
|
||||
|
||||
@@ -168,10 +168,12 @@ def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
|
||||
return session_id not in _cli()._single_query_finalize_attempted_session_ids
|
||||
|
||||
|
||||
def _notify_session_finalize(*, session_id: str | None, platform: str = "cli", reason: str = "shutdown") -> None:
|
||||
def _notify_session_finalize(*, session_id: str | None, platform: str = "cli", reason: str = "shutdown") -> list[str]:
|
||||
"""Fire on_session_finalize; return the plugin messages meant for the user (never raises)."""
|
||||
with suppress(Exception):
|
||||
from hermes_cli.lifecycle import finalize_session
|
||||
finalize_session(session_id=session_id, platform=platform, reason=reason)
|
||||
from hermes_cli.lifecycle import finalize_session, session_end_messages
|
||||
return session_end_messages(finalize_session(session_id=session_id, platform=platform, reason=reason))
|
||||
return []
|
||||
|
||||
|
||||
def _oneshot_agent_and_session(cli):
|
||||
@@ -224,7 +226,10 @@ def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> N
|
||||
return
|
||||
|
||||
try:
|
||||
_notify_session_finalize(session_id=session_id, platform=getattr(agent, "platform", None) or "cli", reason=reason)
|
||||
# stderr: one-shot stdout is the answer scripts parse.
|
||||
for message in _notify_session_finalize(
|
||||
session_id=session_id, platform=getattr(agent, "platform", None) or "cli", reason=reason):
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
finally:
|
||||
_cli()._single_query_finalize_attempted_session_ids.add(session_id)
|
||||
|
||||
|
||||
@@ -53,6 +53,19 @@ def has_hook(hook_name: str) -> bool:
|
||||
return plugins.has_hook(hook_name)
|
||||
|
||||
|
||||
def session_end_messages(results: Any) -> List[str]:
|
||||
"""User-facing text from ``on_session_finalize`` results: a non-empty ``str`` or ``{"message": str}``.
|
||||
|
||||
Surfaces show these out-of-band (CLI print, TUI system line, gateway chat send) — never as a model
|
||||
turn. ``on_session_end`` is per-turn and stays observer-only."""
|
||||
messages: List[str] = []
|
||||
for result in results or ():
|
||||
text = result.get("message") if isinstance(result, dict) else result
|
||||
if isinstance(text, str) and text.strip():
|
||||
messages.append(text.strip())
|
||||
return messages
|
||||
|
||||
|
||||
def finalize_session(**kwargs: Any) -> List[Any]:
|
||||
"""Notify observers and hard-close one core-owned Relay conversation."""
|
||||
_observe("on_session_finalize", **kwargs)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Plugin ``on_session_finalize`` return values reach the user out-of-band on every surface.
|
||||
|
||||
A hook returns ``str`` or ``{"message": str}``; the CLI prints it, the TUI gets it on the
|
||||
``session.close`` result, the messaging gateway sends it to the owning chat via the adapter.
|
||||
No surface starts an agent turn with it. ``on_session_end`` (per turn) stays observer-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import cli as cli_mod # module-level, like sibling CLI tests: importing cli runs bootstrap
|
||||
from hermes_cli import plugins
|
||||
from hermes_cli.plugins import PluginManager
|
||||
|
||||
DIGEST = "Session digest: 3 files touched"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finalize_hook(monkeypatch):
|
||||
"""A real PluginManager whose only hook is an on_session_finalize returning a message."""
|
||||
calls: list[dict] = []
|
||||
|
||||
def _hook(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return {"message": DIGEST}
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr._discovered = True
|
||||
mgr._hooks["on_session_finalize"] = [_hook]
|
||||
monkeypatch.setattr(plugins, "get_plugin_manager", lambda: mgr)
|
||||
return calls
|
||||
|
||||
|
||||
def test_session_end_messages_accepts_str_and_message_dict_only():
|
||||
from hermes_cli.lifecycle import session_end_messages
|
||||
|
||||
assert session_end_messages([" hi ", {"message": "there"}, {"context": "x"}, {"message": 3}, "", None, 7]) == [
|
||||
"hi", "there"]
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cli_new_prints_finalize_message(finalize_hook, capsys):
|
||||
cli = cli_mod.HermesCLI()
|
||||
cli.agent = MagicMock()
|
||||
cli.agent.session_id = "old-session"
|
||||
|
||||
cli.new_session(silent=True)
|
||||
|
||||
assert finalize_hook and finalize_hook[0]["session_id"] == "old-session"
|
||||
assert DIGEST in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cli_exit_prints_finalize_message_after_screen_clear(finalize_hook, capsys, monkeypatch):
|
||||
agent = MagicMock()
|
||||
agent.session_id = "exit-session"
|
||||
monkeypatch.setattr(cli_mod, "_active_agent_ref", agent)
|
||||
monkeypatch.setattr(cli_mod, "_cleanup_done", False)
|
||||
monkeypatch.setattr(cli_mod, "_session_end_messages", [])
|
||||
|
||||
cli_mod._run_cleanup()
|
||||
obj = cli_mod.HermesCLI()
|
||||
obj.conversation_history = []
|
||||
obj._print_exit_summary(clear_screen=False)
|
||||
|
||||
assert DIGEST in capsys.readouterr().out
|
||||
assert cli_mod._session_end_messages == []
|
||||
|
||||
|
||||
def test_one_shot_prints_finalize_message_to_stderr(finalize_hook, capsys, monkeypatch):
|
||||
monkeypatch.setattr(cli_mod, "_single_query_finalize_attempted_session_ids", set())
|
||||
agent = SimpleNamespace(session_id="oneshot-session", platform="cli")
|
||||
|
||||
cli_mod._notify_single_query_session_finalize(SimpleNamespace(agent=agent, session_id="oneshot-session"))
|
||||
|
||||
out = capsys.readouterr()
|
||||
assert DIGEST in out.err
|
||||
assert DIGEST not in out.out # stdout is the answer scripts parse
|
||||
|
||||
|
||||
# ── TUI / Desktop gateway ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tui_session_close_returns_finalize_message(finalize_hook, monkeypatch):
|
||||
from tui_gateway import server
|
||||
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_TURN_SETTLE_BEFORE_CLOSE_SECONDS", 0.0)
|
||||
server._sessions["close-sid"] = {
|
||||
"agent": None, "session_key": "close-key", "history": [], "history_lock": threading.Lock(),
|
||||
"running": False, "slash_worker": None}
|
||||
|
||||
reply = server.handle_request(
|
||||
{"id": "1", "method": "session.close", "params": {"session_id": "close-sid"}})
|
||||
|
||||
assert reply["result"] == {"closed": True, "messages": [DIGEST]}
|
||||
assert finalize_hook[0]["session_id"] == "close-key"
|
||||
|
||||
|
||||
# ── messaging gateway ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _gateway_runner():
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
source = SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="t", chat_type="dm")
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")})
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner._session_model_overrides, runner._session_reasoning_overrides = {}, {}
|
||||
runner._pending_model_notes, runner._background_tasks = {}, set()
|
||||
key = build_session_key(source)
|
||||
entry = SessionEntry(session_key=key, session_id="sess-1", created_at=datetime.now(),
|
||||
updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm", origin=source)
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = entry
|
||||
runner.session_store.reset_session.return_value = entry
|
||||
runner.session_store._entries = {key: entry}
|
||||
runner.session_store._generate_session_key.return_value = key
|
||||
runner._running_agents, runner._pending_messages, runner._pending_approvals = {}, {}, {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache_lock = None
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._format_session_info = lambda: ""
|
||||
return runner, adapter, source, key
|
||||
|
||||
|
||||
def _sent_texts(adapter) -> list[str]:
|
||||
return [c.args[1] for c in adapter.send.await_args_list]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_new_sends_finalize_message_to_owning_chat(finalize_hook):
|
||||
from gateway.platforms.event import MessageEvent
|
||||
|
||||
runner, adapter, source, _key = _gateway_runner()
|
||||
|
||||
reply = await runner._handle_reset_command(MessageEvent(text="/new", source=source, message_id="m1"))
|
||||
|
||||
assert finalize_hook[0]["session_id"] == "sess-1"
|
||||
assert DIGEST in _sent_texts(adapter)
|
||||
assert adapter.send.await_args_list[_sent_texts(adapter).index(DIGEST)].args[0] == "c1"
|
||||
assert DIGEST not in str(reply) # a separate notice, not folded into the /new banner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_shutdown_sends_finalize_message_before_teardown(finalize_hook):
|
||||
runner, adapter, _source, key = _gateway_runner()
|
||||
runner._cleanup_agent_resources_off_loop = AsyncMock()
|
||||
runner._flush_agent_transcript_at_shutdown = lambda agent: None
|
||||
|
||||
await runner._finalize_shutdown_agents({key: SimpleNamespace(session_id="sess-1")})
|
||||
|
||||
assert DIGEST in _sent_texts(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_finalize_without_message_sends_nothing(monkeypatch):
|
||||
mgr = PluginManager()
|
||||
mgr._discovered = True
|
||||
mgr._hooks["on_session_finalize"] = [lambda **_: None]
|
||||
monkeypatch.setattr(plugins, "get_plugin_manager", lambda: mgr)
|
||||
runner, adapter, _source, key = _gateway_runner()
|
||||
runner._cleanup_agent_resources_off_loop = AsyncMock()
|
||||
runner._flush_agent_transcript_at_shutdown = lambda agent: None
|
||||
|
||||
await runner._finalize_shutdown_agents({key: SimpleNamespace(session_id="sess-1")})
|
||||
|
||||
adapter.send.assert_not_awaited()
|
||||
@@ -392,6 +392,8 @@ class SessionCloseParams(SessionParams):
|
||||
|
||||
class SessionCloseResult(Result):
|
||||
closed: bool # False when the runtime id was already gone
|
||||
# Plugin ``on_session_finalize`` text for the user; shown out-of-band, never a model turn.
|
||||
messages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
method("session.close", params=SessionCloseParams, result=SessionCloseResult,
|
||||
|
||||
@@ -2203,7 +2203,7 @@ def _(rid, params: dict, session: dict) -> dict:
|
||||
def _(rid, params: dict) -> dict:
|
||||
with _session_resume_lock: # lock only the ownership claim; finalization must not block resumes
|
||||
session = _pop_session_by_id(params.get("session_id", ""))
|
||||
return _ok(rid, {"closed": _teardown_popped_session(session, end_reason="tui_close")})
|
||||
return _ok(rid, {"closed": _teardown_popped_session(session), "messages": (session or {}).get("_end_msgs", [])})
|
||||
|
||||
|
||||
@_session_method("session.branch", live=True)
|
||||
|
||||
@@ -46,14 +46,14 @@ def _start_session_work(target, *, name: str, session: dict | None = None):
|
||||
raise
|
||||
|
||||
|
||||
def _notify_session_boundary(event_type: str, session_id: str | None, platform: str | None = None) -> None:
|
||||
"""Fire session lifecycle hooks with CLI parity."""
|
||||
def _notify_session_boundary(event_type: str, session_id: str | None, platform: str | None = None) -> list[str]:
|
||||
"""Fire session lifecycle hooks with CLI parity; returns plugin ``on_session_finalize`` user messages."""
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_cli.lifecycle import finalize_session, invoke_hook
|
||||
from hermes_cli.lifecycle import finalize_session, invoke_hook, session_end_messages
|
||||
if event_type == "on_session_finalize":
|
||||
finalize_session(session_id=session_id, platform=_resolve_agent_platform(platform))
|
||||
else:
|
||||
invoke_hook(event_type, session_id=session_id, platform=_resolve_agent_platform(platform))
|
||||
return session_end_messages(finalize_session(session_id=session_id, platform=_resolve_agent_platform(platform)))
|
||||
invoke_hook(event_type, session_id=session_id, platform=_resolve_agent_platform(platform))
|
||||
return []
|
||||
|
||||
|
||||
_SESSION_OWNERSHIP_UNAVAILABLE = "Hermes could not safely reserve this session. Try again."
|
||||
@@ -395,7 +395,8 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
|
||||
|
||||
session_key = session.get("session_key")
|
||||
session_id = getattr(agent, "session_id", None) or session_key
|
||||
_notify_session_boundary("on_session_finalize", session_id, _session_source(session))
|
||||
# Returned to the client by ``session.close`` (the TUI shows them after its /new reset); reaper paths have no client.
|
||||
session["_end_msgs"] = _notify_session_boundary("on_session_finalize", session_id, _session_source(session))
|
||||
# End the state.db row so it doesn't linger as a ghost in /resume. Use session_id (agent.session_id), not
|
||||
# session_key: after compression the key may be the stale ended parent while session_id is the live continuation.
|
||||
# Fix for #20001.
|
||||
|
||||
@@ -548,7 +548,7 @@ export interface SlashHandlerContext {
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
}
|
||||
session: {
|
||||
closeSession: (targetSid?: null | string) => Promise<unknown>
|
||||
closeSession: (targetSid?: null | string, deferMessages?: boolean) => Promise<unknown>
|
||||
die: () => void
|
||||
dieWithCode: (code: number) => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
|
||||
@@ -146,10 +146,19 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
sys
|
||||
} = opts
|
||||
|
||||
// Plugin on_session_finalize text comes back on the close result and is shown as system lines (never a
|
||||
// model turn). `deferMessages`: the caller resets the transcript next and shows them itself afterwards.
|
||||
const closeSession = useCallback(
|
||||
(targetSid?: null | string) =>
|
||||
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
|
||||
[rpc]
|
||||
async (targetSid?: null | string, deferMessages = false) => {
|
||||
const closed = targetSid ? await rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : null
|
||||
|
||||
if (!deferMessages) {
|
||||
closed?.messages?.forEach(message => sys(message))
|
||||
}
|
||||
|
||||
return closed
|
||||
},
|
||||
[rpc, sys]
|
||||
)
|
||||
|
||||
const cancelResumeScrollRef = useRef<null | (() => void)>(null)
|
||||
@@ -207,10 +216,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
}
|
||||
|
||||
const previousSid = getUiState().sid
|
||||
|
||||
if (!keepCurrent) {
|
||||
await closeSession(previousSid)
|
||||
}
|
||||
const closed = keepCurrent ? null : await closeSession(previousSid, true)
|
||||
|
||||
const r = await rpc<SessionCreateResponse>('session.create', {
|
||||
cols: colsRef.current,
|
||||
@@ -257,6 +263,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
sys(msg)
|
||||
}
|
||||
|
||||
// After the reset above, so the closed session's plugin messages stay visible.
|
||||
closed?.messages?.forEach(message => sys(message))
|
||||
|
||||
if (requestedTitle) {
|
||||
rpc<SessionTitleResponse>('session.title', {
|
||||
session_id: r.session_id,
|
||||
|
||||
@@ -298,6 +298,8 @@ export interface SessionBranchResponse {
|
||||
|
||||
export interface SessionCloseResponse {
|
||||
closed?: boolean
|
||||
/** Plugin `on_session_finalize` text for the user (shown as system lines, never a model turn). */
|
||||
messages?: string[]
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -966,7 +966,7 @@ def my_callback(session_id: str, completed: bool, interrupted: bool,
|
||||
1. **`agent/turn_finalizer.py`** — at the end of every `run_conversation()` call (`agent/conversation_loop.py`), after all cleanup. Always fires, even if the turn errored.
|
||||
2. **`cli.py`** — in the CLI's atexit handler, but **only** if the agent was mid-turn (`_agent_running=True`) when the exit occurred. This catches Ctrl+C and `/exit` during processing. In this case, `completed=False` and `interrupted=True`.
|
||||
|
||||
**Return value:** Ignored.
|
||||
**Return value:** Ignored. Despite its name this hook fires after **every turn**, so it can't show the user anything. To show something when a session ends, return a message from [`on_session_finalize`](#on_session_finalize).
|
||||
|
||||
**Use cases:** Flushing buffers, closing connections, persisting session state, logging session duration, cleanup of resources initialized in `on_session_start`.
|
||||
|
||||
@@ -1028,7 +1028,28 @@ def my_callback(session_id: str | None, platform: str, **kwargs):
|
||||
|
||||
**Fires:** In CLI/TUI teardown (including the end of a `hermes -z` one-shot run, success or failure) and in gateway reset or shutdown paths. Gateway shutdown can finalize without a matching `on_session_reset`.
|
||||
|
||||
**Return value:** Ignored.
|
||||
**Return value — show the user a message:** return a non-empty `str`, or a dict with a `"message"` string, and Hermes shows it to the user as a notice. It never becomes a model turn and never edits the assistant's reply. Return `None` to show nothing. Where it appears:
|
||||
|
||||
| Surface | Shown as |
|
||||
|---------|---------|
|
||||
| CLI `/new` | Printed in the terminal before the new session starts. |
|
||||
| CLI quit | Printed above the exit summary (`hermes --resume …`). |
|
||||
| `hermes chat -q` / `-z` one-shot | Printed to **stderr**, so stdout still holds only the answer. |
|
||||
| TUI `/new` | A system line in the fresh session's transcript. |
|
||||
| Messaging gateway `/new` or `/reset` | Sent to the chat that owned the session, as its own message. |
|
||||
| Gateway shutdown / restart | Sent to each active chat before the adapters disconnect. |
|
||||
|
||||
It is not delivered when nobody is there to see it: idle-expiry and cache eviction in the gateway, quitting the TUI, TUI/Desktop sessions closed by the reaper or a disconnect, and Desktop new-chat or chat delete. It is also not delivered from cron jobs, which don't finalize sessions. In those cases the message is dropped and the hook still runs.
|
||||
|
||||
```python
|
||||
def digest(session_id, **kwargs):
|
||||
count = _edits.pop(session_id, 0)
|
||||
if count:
|
||||
return {"message": f"📝 This session edited {count} file(s)."}
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("on_session_finalize", digest)
|
||||
```
|
||||
|
||||
**Use cases:** Persist final session metrics before the session ID is discarded, close per-session resources, emit a final telemetry event, drain queued writes.
|
||||
|
||||
|
||||
Reference in new issue
Block a user