feat(session): add /handoff command for cross-platform session transfer

Adds /handoff <platform> CLI command that queues the current session for
resume on the configured home channel of any messaging platform.

CLI side:
- /handoff telegram — marks session in shared DB, sends summary to
  the Telegram home channel via send_message
- /handoff discord — same for Discord
- Supports telegram, discord, slack, whatsapp, signal, matrix

Gateway side:
- On new session creation, checks for pending handoffs for the
  incoming message's platform
- If found, loads the CLI session's full conversation history and
  injects it into the context prompt as a handoff transcript
- Agent continues the conversation seamlessly

Files:
- hermes_state.py: handoff_pending, handoff_platform columns + helpers
- cli.py: _handle_handoff_command dispatch + handler
- hermes_cli/commands.py: CommandDef entry
- gateway/run.py: handoff detection in _handle_message_with_agent
- tests/hermes_cli/test_session_handoff.py: 8 tests
This commit is contained in:
kshitijk4poor 2026-05-09 23:17:32 +05:30 committed by Teknium
parent 6e5c49bdc4
commit 878611a79d
5 changed files with 294 additions and 0 deletions

84
cli.py
View file

@ -5484,6 +5484,88 @@ class HermesCLI:
else:
print("(^_^)v New session started!")
def _handle_handoff_command(self, cmd_original: str) -> None:
"""Handle /handoff <platform> — hand off current session to a messaging platform."""
from hermes_state import format_session_db_unavailable
parts = cmd_original.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
_cprint(" Usage: /handoff <platform>")
_cprint(" Supported: telegram, discord, slack, whatsapp, signal, matrix")
_cprint(" The session will become available on that platform's home channel.")
return
platform = parts[1].strip().lower()
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
if platform not in supported:
_cprint(f" Unknown platform '{platform}'. Supported: {', '.join(sorted(supported))}")
return
# Ensure session is in the DB
if not self._session_db:
from hermes_state import SessionDB
self._session_db = SessionDB()
if not self._session_db:
_cprint(f" {format_session_db_unavailable()}")
return
# Make sure the session has a title
session_title = ""
try:
session_meta = self._session_db.get_session(self.session_id)
if session_meta:
session_title = session_meta.get("title") or ""
except Exception:
pass
if not session_title:
# Auto-title from conversation if not set
if hasattr(self, "agent") and self.agent and self.conversation_history:
last_user_msgs = [m for m in self.conversation_history[-6:] if m.get("role") == "user"]
if last_user_msgs:
title = last_user_msgs[0].get("content", "")[:60]
title = title.replace("\n", " ").strip()
if title:
session_title = title
self._session_db.set_session_title(self.session_id, title)
if not session_title:
session_title = "untitled session"
# Mark session for handoff
ok = self._session_db.set_handoff_pending(self.session_id, platform)
if not ok:
_cprint(f" Session is already pending handoff or not found.")
return
_cprint(f" Session '{session_title}' queued for handoff to {platform}.")
_cprint(f" The session will resume when the next message arrives on the {platform} home channel.")
# Also try to send a notification via send_message
try:
summary_lines = ["Handoff from CLI", f"Session: {session_title}"]
if hasattr(self, "agent") and self.agent:
last_msgs = self.conversation_history[-4:] if self.conversation_history else []
for msg in last_msgs:
role = msg.get("role", "")
content = str(msg.get("content", ""))[:120]
if content.strip():
summary_lines.append(f"[{role}] {content}")
summary = "\n".join(summary_lines)
from tools.send_message_tool import send_message_tool
result_json = send_message_tool({"target": platform, "message": summary})
import json
result = json.loads(result_json)
if result.get("success"):
_cprint(f" Notification sent to {platform} home channel.")
else:
err = result.get("error", "unknown error")
_cprint(f" Could not send notification to {platform}: {err}")
except Exception as e:
_cprint(f" Could not send notification: {e}")
def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
parts = cmd_original.split(None, 1)
@ -6910,6 +6992,8 @@ class HermesCLI:
else:
from hermes_state import format_session_db_unavailable
_cprint(f" {format_session_db_unavailable()}")
elif canonical == "handoff":
self._handle_handoff_command(cmd_original)
elif canonical == "new":
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None

View file

@ -6648,6 +6648,46 @@ class GatewayRunner:
# Build the context prompt to inject
context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii)
# Check for pending CLI handoff
if _is_new_session and self._session_db:
try:
platform_key = source.platform.value if source.platform else ""
handoff = self._session_db.find_pending_handoff(platform_key)
if handoff:
cli_session_id = handoff["id"]
cli_messages = self._session_db.get_messages(cli_session_id)
if cli_messages:
# Cap to last 200 messages to avoid context blowup
cli_messages = cli_messages[-200:]
transcript = []
for msg in cli_messages:
role = msg.get("role", "unknown")
content = str(msg.get("content") or "")
if content.strip():
label = {"user": "User", "assistant": "Assistant",
"system": "System", "tool": "Tool"}.get(role, role.title())
transcript.append(f"{label}: {content}")
if transcript:
handoff_title = handoff.get("title") or "untitled"
handoff_context = (
f"[Handoff from CLI session '{handoff_title}'. "
f"Continue the conversation below where it left off.]"
)
context_prompt = (
handoff_context
+ "\n\n--- Previous conversation ---\n"
+ "\n\n".join(transcript)
+ "\n--- End of previous conversation ---\n\n"
+ context_prompt
)
self._session_db.clear_handoff_pending(cli_session_id)
logger.info(
"Handoff: CLI session %s handed off to %s chat %s",
cli_session_id, platform_key, source.chat_id,
)
except Exception:
logger.debug("Handoff check failed", exc_info=True)
# If the previous session expired and was auto-reset, prepend a notice
# so the agent knows this is a fresh conversation (not an intentional /reset).

View file

@ -79,6 +79,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
CommandDef("title", "Set a title for the current session", "Session",
args_hint="[name]"),
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
args_hint="<platform>", cli_only=True),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session",

View file

@ -215,6 +215,8 @@ CREATE TABLE IF NOT EXISTS sessions (
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
handoff_pending INTEGER DEFAULT 0,
handoff_platform TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@ -2861,3 +2863,46 @@ class SessionDB:
return result
# ── Handoff (cross-platform session transfer) ──────────────────────────
def set_handoff_pending(self, session_id: str, platform: str) -> bool:
"""Mark a session as pending handoff to the given platform.
Returns True if the session was found and updated.
"""
def _do(conn):
cur = conn.execute(
"UPDATE sessions SET handoff_pending = 1, handoff_platform = ? "
"WHERE id = ? AND handoff_pending = 0",
(platform, session_id),
)
return cur.rowcount > 0
return self._execute_write(_do)
def find_pending_handoff(self, platform: str) -> Optional[Dict[str, Any]]:
"""Find the most recent session pending handoff for a platform.
Returns the session dict or None.
"""
try:
cur = self._conn.execute(
"SELECT * FROM sessions "
"WHERE handoff_pending = 1 AND handoff_platform = ? "
"ORDER BY started_at DESC LIMIT 1",
(platform,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception:
return None
def clear_handoff_pending(self, session_id: str) -> None:
"""Clear the handoff_pending flag on a session."""
def _do(conn):
conn.execute(
"UPDATE sessions SET handoff_pending = 0, handoff_platform = NULL "
"WHERE id = ?",
(session_id,),
)
self._execute_write(_do)

View file

@ -0,0 +1,123 @@
"""Tests for session handoff (CLI to gateway platform)."""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
from hermes_state import SessionDB
class TestHandoffDB:
"""Test the handoff columns and helper methods on SessionDB."""
@pytest.fixture
def db(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
db = SessionDB(db_path=home / "state.db")
yield db
def _make_session(self, db, session_id, source="cli", title=None):
"""Insert a session row directly for testing."""
def _do(conn):
conn.execute(
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
"VALUES (?, ?, ?, ?)",
(session_id, source, title, time.time()),
)
db._execute_write(_do)
def test_handoff_columns_exist(self, db):
"""Verify handoff columns are in the sessions table after init."""
db._conn.execute("SELECT handoff_pending, handoff_platform FROM sessions LIMIT 0")
def test_set_handoff_pending(self, db):
"""Mark a session for handoff."""
session_id = "test-session-001"
self._make_session(db, session_id)
ok = db.set_handoff_pending(session_id, "telegram")
assert ok is True
session = db.get_session(session_id)
assert session["handoff_pending"] == 1
assert session["handoff_platform"] == "telegram"
def test_set_handoff_pending_no_double_mark(self, db):
"""Re-marking an already-pending session returns False."""
session_id = "test-session-002"
self._make_session(db, session_id)
ok1 = db.set_handoff_pending(session_id, "telegram")
assert ok1 is True
ok2 = db.set_handoff_pending(session_id, "discord")
assert ok2 is False # already pending
def test_find_pending_handoff(self, db):
"""Find a session pending handoff for a given platform."""
sid = "test-session-003"
self._make_session(db, sid)
db.set_handoff_pending(sid, "telegram")
handoff = db.find_pending_handoff("telegram")
assert handoff is not None
assert handoff["id"] == sid
# Should not find for other platforms
assert db.find_pending_handoff("discord") is None
def test_clear_handoff_pending(self, db):
"""Clear the handoff flag."""
sid = "test-session-004"
self._make_session(db, sid)
db.set_handoff_pending(sid, "telegram")
db.clear_handoff_pending(sid)
session = db.get_session(sid)
assert session["handoff_pending"] == 0
def test_full_handoff_flow(self, db):
"""End-to-end: mark → find → load messages → clear."""
sid = "test-session-005"
self._make_session(db, sid, title="my session")
db.append_message(sid, "user", "Hello")
db.append_message(sid, "assistant", "Hi there!")
# CLI side: mark for handoff
ok = db.set_handoff_pending(sid, "telegram")
assert ok is True
# Gateway side: find pending handoff
handoff = db.find_pending_handoff("telegram")
assert handoff is not None
assert handoff["id"] == sid
assert handoff["title"] == "my session"
# Load messages for context
messages = db.get_messages(sid)
assert len(messages) == 2
assert messages[0]["role"] == "user"
assert messages[1]["role"] == "assistant"
# Clear after injecting
db.clear_handoff_pending(sid)
assert db.find_pending_handoff("telegram") is None
class TestHandoffCommand:
"""Test the CLI /handoff command handler."""
def test_command_registered(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("handoff")
assert cmd is not None
assert cmd.name == "handoff"
assert cmd.category == "Session"
def test_invalid_platform(self):
"""Test that unknown platforms are rejected."""
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
assert "telegram" in supported
assert "foo" not in supported