mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-23 05:31:23 +00:00
feat(session): make /handoff actually transfer the session live
Builds on @kshitijk4poor's CLI handoff stub. The original PR's flow
deferred everything to whenever a real user happened to message the
target platform; this rewrites it so the gateway picks up handoffs
immediately and the destination chat just starts working.
State machine on sessions table replaces the boolean flag:
None -> 'pending' -> 'running' -> ('completed' | 'failed')
plus handoff_error for failure reasons. CLI request_handoff /
get_handoff_state / list_pending_handoffs / claim_handoff /
complete_handoff / fail_handoff helpers wrap the transitions.
CLI side (cli.py): /handoff <platform> validates the platform's home
channel via load_gateway_config, refuses if the agent is mid-turn,
flips the row to 'pending', and poll-blocks (60s) on terminal state.
On 'completed' it prints the /resume hint and exits the CLI like
/quit. On 'failed' or timeout it surfaces the reason and the CLI
session stays intact.
Gateway side (gateway/run.py): new _handoff_watcher background task
scans state.db every 2s, atomically claims pending rows, and runs
_process_handoff for each. _process_handoff:
1. Resolves the platform's home channel.
2. Asks the adapter for a fresh thread via the new
create_handoff_thread(parent_chat_id, name) capability so the
handed-off conversation gets its own scrollback. Adapters that
don't support threads (or fail) return None and the watcher
falls back to the home channel directly.
3. Constructs a SessionSource keyed as 'thread' when a thread was
created, 'dm' otherwise, then session_store.switch_session
re-binds the destination key to the CLI session_id. The full
role-aware transcript replays via load_transcript on the next
turn (no flat-text injection into context_prompt).
4. Forges a synthetic MessageEvent(internal=True) with the handoff
notice and dispatches through _handle_message; the agent runs
against the loaded transcript and adapter.send delivers the
reply.
5. Marks the row 'completed' on success, 'failed' (+error) on any
exception.
Adapter capability (gateway/platforms/base.py): create_handoff_thread
default returns None. Three overrides:
- Telegram (gateway/platforms/telegram.py): wraps _create_dm_topic
so DM topics (Bot API 9.4+) and forum supergroups both work.
- Discord (gateway/platforms/discord.py): parent.create_thread on
text channels with a seed-message + message.create_thread
fallback for permission edge cases. Skips DMs and other
non-thread-capable parents.
- Slack (gateway/platforms/slack.py): posts a seed message and
returns its ts as the thread anchor — Slack threads are
message-anchored.
In thread mode, build_session_key keys the destination without
user_id (thread_sessions_per_user defaults to False) so the synthetic
turn and any later real-user message in the thread share the same
session_key — seamless takeover without race.
CommandDef stays cli_only=True (handoff is initiated from the CLI;
gateway exposes /resume for the reverse direction).
Removed the original PR's _handle_message_with_agent handoff hook
(transcript-as-text injection into context_prompt) and the
send_message_tool notification — both replaced by the watcher path.
Tests rewritten around the new state machine: 13/13 pass.
E2E-validated thread + no-thread paths and the failure path against
real worktree imports with mocked adapters.
This commit is contained in:
parent
878611a79d
commit
00ce5f04d9
8 changed files with 737 additions and 189 deletions
|
|
@ -215,8 +215,9 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
handoff_pending INTEGER DEFAULT 0,
|
||||
handoff_state TEXT,
|
||||
handoff_platform TEXT,
|
||||
handoff_error TEXT,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
|
|
@ -2864,45 +2865,102 @@ class SessionDB:
|
|||
return result
|
||||
|
||||
# ── Handoff (cross-platform session transfer) ──────────────────────────
|
||||
#
|
||||
# State machine:
|
||||
# None — no handoff in flight
|
||||
# "pending" — CLI requested handoff, gateway hasn't picked it up yet
|
||||
# "running" — gateway is processing (session switch + synthetic turn)
|
||||
# "completed"— gateway successfully delivered the synthetic turn
|
||||
# "failed" — gateway hit an error; reason in handoff_error
|
||||
#
|
||||
# The CLI writes "pending" then poll-waits for terminal state. The gateway
|
||||
# watcher transitions pending→running→{completed,failed}.
|
||||
|
||||
def set_handoff_pending(self, session_id: str, platform: str) -> bool:
|
||||
def request_handoff(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.
|
||||
Returns True if the row was found and not already in flight; False if
|
||||
the session is already in a non-terminal handoff state.
|
||||
"""
|
||||
def _do(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE sessions SET handoff_pending = 1, handoff_platform = ? "
|
||||
"WHERE id = ? AND handoff_pending = 0",
|
||||
"UPDATE sessions "
|
||||
"SET handoff_state = 'pending', "
|
||||
" handoff_platform = ?, "
|
||||
" handoff_error = NULL "
|
||||
"WHERE id = ? AND (handoff_state IS NULL "
|
||||
" OR handoff_state IN ('completed', 'failed'))",
|
||||
(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.
|
||||
def get_handoff_state(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read the current handoff state for a session.
|
||||
|
||||
Returns the session dict or None.
|
||||
Returns ``{"state", "platform", "error"}`` or None if the session has
|
||||
no handoff record.
|
||||
"""
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"SELECT handoff_state, handoff_platform, handoff_error "
|
||||
"FROM sessions WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"state": row["handoff_state"],
|
||||
"platform": row["handoff_platform"],
|
||||
"error": row["handoff_error"],
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def list_pending_handoffs(self) -> List[Dict[str, Any]]:
|
||||
"""Return all sessions in handoff_state='pending', oldest first.
|
||||
|
||||
Used by the gateway's handoff watcher.
|
||||
"""
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"SELECT * FROM sessions "
|
||||
"WHERE handoff_pending = 1 AND handoff_platform = ? "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(platform,),
|
||||
"WHERE handoff_state = 'pending' "
|
||||
"ORDER BY started_at ASC"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
except Exception:
|
||||
return None
|
||||
return []
|
||||
|
||||
def clear_handoff_pending(self, session_id: str) -> None:
|
||||
"""Clear the handoff_pending flag on a session."""
|
||||
def claim_handoff(self, session_id: str) -> bool:
|
||||
"""Atomically transition pending → running. Returns True if claimed."""
|
||||
def _do(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE sessions SET handoff_state = 'running' "
|
||||
"WHERE id = ? AND handoff_state = 'pending'",
|
||||
(session_id,),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
return self._execute_write(_do)
|
||||
|
||||
def complete_handoff(self, session_id: str) -> None:
|
||||
"""Mark a handoff as completed."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET handoff_pending = 0, handoff_platform = NULL "
|
||||
"WHERE id = ?",
|
||||
"UPDATE sessions SET handoff_state = 'completed', "
|
||||
"handoff_error = NULL WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def fail_handoff(self, session_id: str, error: str) -> None:
|
||||
"""Mark a handoff as failed and record the reason."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET handoff_state = 'failed', "
|
||||
"handoff_error = ? WHERE id = ?",
|
||||
(error[:500], session_id),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue