Files
Hermes Agent 66bc259712 fix(gateway): declare cwd_explicit on the session.create contract
The desktop ships cwd_explicit provenance with session.create (#52589),
but the wire contract (extra=forbid) rejected the unknown key, so every
remote-mode create failed with 'out of sync (different versions)' and the
composer never accepted the draft (Desktop core E2E remote-topology).
Local-backend lanes never sent it (a bare new chat is detached, no cwd).

Regenerate the shared contract TS + OpenRPC from the updated registry.
2026-09-25 20:00:33 -05:00

734 lines
23 KiB
Python

"""Session lifecycle contracts (``tui_gateway/methods_session.py``): create / resume / activate /
close, the live-session snapshot those share, history + compression + undo, mid-turn corrections,
listing/browsing stored rows, spawn-tree snapshots, event replay and the stateless one-shot LLM call.
"""
from __future__ import annotations
from pydantic import Field
from .base import JsonValue, Params, Result, WireEnum
from .common import (OpenModel, PendingApproval, ProfileParams, SessionLiveInfo, SessionParams, TranscriptMessage,
Usage)
from .connectors_operation import ConnectionRequestPayload
from .registry import method
# ── shared live-session snapshot ──────────────────────────────────────────────────────────────
class OpenRequestEntry(Result):
"""One unanswered server→client request (``server_requests.Request.snapshot``); the reconnecting
client re-delivers it to its request handlers."""
id: str
method: str
params: dict[str, JsonValue]
class InflightTurn(Result):
"""``session_auto_continue._inflight_snapshot``: the live (or retained failed) turn a reconnecting
client rebuilds its bubbles from."""
assistant: str = ""
streaming: bool = False
user: str = ""
display_kind: str | None = None
display_metadata: dict[str, JsonValue] | None = None
corrections: list[str] | None = None
correction_offsets: list[int] | None = None
error: str | None = None
status: str | None = None
recoverable: bool | None = None
error_surface: dict[str, JsonValue] | None = None
class QueuedPrompt(Result):
user: str
class TodoState(Result):
"""``tool_progress._normalize_todo_state``: the authoritative todo snapshot."""
todos: list[dict[str, JsonValue]]
revision: int
class AutoContinue(Result):
"""A crash-interrupted turn was scheduled to continue right after this resume."""
attempt: int
interrupted_at: float
class LiveSessionStatus(WireEnum):
idle = "idle"
starting = "starting"
waiting = "waiting"
working = "working"
streaming = "streaming" # lazy watch window whose child run is active
resuming = "resuming" # deferred hydration in flight
class LiveSessionSnapshot(Result):
"""Union of ``server._live_session_payload``, ``methods_session._resume_response`` and the
live-unpersisted resume path; keys only some paths produce are optional."""
session_id: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
stored_session_id: str | None = None
resumed: str | None = None
session_key: str | None = None
messages_omitted: bool | None = None
hydrating: bool | None = None
running: bool | None = None
turn_started_at: float | None = None
started_at: float | None = None
status: str | None = None # a LiveSessionStatus value
inflight: InflightTurn | None = None
queued: QueuedPrompt | None = None
pending_approval: PendingApproval | None = None
open_requests: list[OpenRequestEntry] | None = None
# The open connection operation (``tools/connectors/live.current``) as its ``connection.request``
# payload: the card restores with the server's deadline after a reconnect or restart.
pending_connection: ConnectionRequestPayload | None = None
todo_state: TodoState | None = None
auto_continue: AutoContinue | None = None
# ── session.create ────────────────────────────────────────────────────────────────────────────
class SeedMessage(Params):
"""One create-time transcript row (``session_history._coerce_seed_history``); ``text`` is the
legacy alias of ``content``; only ``display_kind: "hidden"`` is accepted from the wire. Clients
forward stored rows verbatim (``_row_id``, ``timestamp``, …) and the coercer drops what it does
not use, so the row stays open."""
model_config = Params.model_config | {"extra": "allow"}
role: str
content: str | None = None
text: str | None = None
display_kind: str | None = None
class SessionCreateParams(ProfileParams):
cols: int | None = None
source: str | None = None
cwd: str | None = None
# #52589: provenance for ``cwd`` — true only for a deliberate workspace pick;
# an inherited app-global workspace must yield to a named profile's terminal.cwd.
cwd_explicit: bool | None = None
messages: list[SeedMessage] | None = None
parent_session_id: str | None = None
title: str | None = None
model: str | None = None
provider: str | None = None
reasoning_effort: str | None = None
fast: bool | None = None # presence is the contract: omitted inherits, true pins priority, false pins normal
close_on_disconnect: bool = False
hidden: bool = False
room_plumbing: bool = False
follow_profile_config: bool = False
class SessionCreateResult(Result):
session_id: str
stored_session_id: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
method("session.create", params=SessionCreateParams, result=SessionCreateResult,
doc="Mint a live session (agent builds after the reply); a DB row appears on the first prompt unless seeded.")
class SessionBranchStoredParams(ProfileParams):
parent_session_id: str = Field(min_length=1)
cols: int | None = None
source: str | None = None
cwd: str | None = None
class SessionBranchStoredResult(Result):
session_id: str
stored_session_id: str
message_count: int
messages_omitted: bool
info: SessionLiveInfo
method("session.branch_stored", params=SessionBranchStoredParams, result=SessionBranchStoredResult,
doc="Whole-session branch of a stored parent: the owning backend reads and copies the transcript, "
"which never crosses the wire (a separate method so an older gateway fails loudly, not with an empty "
"branch).")
# ── session.resume / activate ─────────────────────────────────────────────────────────────────
class SessionResumeParams(SessionParams):
"""``session_id`` is the STORED id (or an exact title); the reply's ``session_id`` is the runtime id."""
cols: int | None = None
source: str | None = None
lazy: bool = False
defer_history: bool = False
omit_messages: bool = False
eager_build: bool = False
close_on_disconnect: bool = False
class SessionResumeResult(LiveSessionSnapshot):
pass
method("session.resume", params=SessionResumeParams, result=SessionResumeResult,
doc="Attach to a stored session: reuse it if live here, else lazy / deferred / cold / eager rebuild.")
class SessionActivateParams(SessionParams):
cols: int | None = None # sent by the desktop; the handler keeps the session's current width
omit_messages: bool = False
class SessionActivateResult(LiveSessionSnapshot):
pass
method("session.activate", params=SessionActivateParams, result=SessionActivateResult,
doc="Attach the frontend to a live session without closing the previously focused one.")
# ── listing ───────────────────────────────────────────────────────────────────────────────────
class SessionListParams(ProfileParams):
title: str | None = None # exact-title lookup (title as identity); windowless
limit: int | None = None
include_hidden: bool = False
class SessionListRow(Result):
"""``methods_session._session_row_summary``; ``resolved_id`` only on a title lookup that followed a
compression lineage to its tip."""
id: str
resolved_id: str | None = None
title: str = ""
preview: str = ""
started_at: float = 0
message_count: int = 0
source: str = ""
class SessionListResult(Result):
sessions: list[SessionListRow]
method("session.list", params=SessionListParams, result=SessionListResult,
doc="Human-facing stored sessions, most recent first (sub-agent / kanban sources denied).")
class SessionMostRecentParams(ProfileParams):
pass
class SessionMostRecentResult(Result):
session_id: str | None
title: str | None = None
started_at: float | None = None
source: str | None = None
method("session.most_recent", params=SessionMostRecentParams, result=SessionMostRecentResult,
doc="Most recent human-facing session; errors fold into a null session_id.")
class SessionActiveListParams(ProfileParams):
current_session_id: str | None = None
class SessionActiveItem(Result):
"""``server._session_live_item``."""
current: bool
id: str
last_active: float
message_count: int
model: str
preview: str
session_key: str
started_at: float
status: LiveSessionStatus
title: str
class SessionActiveListResult(Result):
sessions: list[SessionActiveItem]
method("session.active_list", params=SessionActiveListParams, result=SessionActiveListResult,
doc="Live sessions in this process, insertion order (not a DB browser).")
# ── stored-row mutation ───────────────────────────────────────────────────────────────────────
class SessionDeleteParams(SessionParams):
"""``session_id`` is the STORED id."""
class SessionDeleteResult(Result):
deleted: str
method("session.delete", params=SessionDeleteParams, result=SessionDeleteResult,
doc="Delete a stored session + transcripts; refused while it is live here.")
class SessionTitleParams(SessionParams):
title: str | None = None # absent: read; present: set (non-empty)
class SessionTitleResult(Result):
title: str
session_key: str | None = None
pending: bool | None = None # True: no row yet, applied on the first turn
method("session.title", params=SessionTitleParams, result=SessionTitleResult,
doc="Read or set a live session's title; a title set before the row exists is queued.")
class SessionSetHiddenParams(Params):
"""``session_id`` is a live runtime id first, else a stored id / key / title."""
session_id: str
hidden: bool = True
profile: str | None = None
class SessionSetHiddenResult(Result):
hidden: bool
session_key: str
method("session.set_hidden", params=SessionSetHiddenParams, result=SessionSetHiddenResult,
doc="Set/clear hidden (out of the default list, still resumable by its owner) on a session + lineage.")
class SessionWorkspaceMoveParams(ProfileParams):
session_key: str
cwd: str
class SessionWorkspaceMoveResult(Result):
cwd: str
branch: str | None = None
git_repo_root: str | None = None
method("session.workspace.move", params=SessionWorkspaceMoveParams, result=SessionWorkspaceMoveResult,
doc="Re-home a stored session's workspace; git identity is replaced and a live agent follows.")
class SessionCwdSetParams(SessionParams):
cwd: str
class SessionCwdSetResult(SessionLiveInfo):
"""The refreshed ``session.info`` view (full agent view, or the lazy shape)."""
method("session.cwd.set", params=SessionCwdSetParams, result=SessionCwdSetResult,
doc="Change a live, idle session's working directory.")
# ── live-session lifecycle ────────────────────────────────────────────────────────────────────
class SessionCloseParams(SessionParams):
pass
class SessionCloseResult(Result):
closed: bool # False when the runtime id was already gone
method("session.close", params=SessionCloseParams, result=SessionCloseResult,
doc="Tear down a live session (its stored row stays resumable).")
class SessionBranchParams(SessionParams):
name: str | None = None
count: int | None = None # keep only the first N rows of the source history
class SessionBranchResult(Result):
session_id: str
stored_session_id: str
title: str
parent: str
message_count: int
messages: list[TranscriptMessage]
info: SessionLiveInfo
method("session.branch", params=SessionBranchParams, result=SessionBranchResult,
doc="Fork a live session into a new stored child that shares the parent's history so far.")
class SessionBranchWholeParams(SessionParams):
name: str | None = None
class SessionBranchWholeResult(Result):
session_id: str
stored_session_id: str
title: str
parent: str
message_count: int
messages_omitted: bool
info: SessionLiveInfo
method("session.branch_whole", params=SessionBranchWholeParams, result=SessionBranchWholeResult,
doc="session.branch of the whole history without echoing the copied transcript back.")
class SessionUndoParams(SessionParams):
pass
class SessionUndoResult(Result):
removed: int
method("session.undo", params=SessionUndoParams, result=SessionUndoResult,
doc="Drop the last user turn (and everything after it) from an idle session.")
class SessionSaveParams(SessionParams):
pass
class SessionSaveResult(Result):
"""Under turn isolation the compute host's result passes through verbatim."""
file: str | None = None
model_config = Result.model_config | {"extra": "allow"}
method("session.save", params=SessionSaveParams, result=SessionSaveResult,
doc="Export the transcript to ~/.hermes/sessions/saved (classic /save).")
class SessionStatusParams(SessionParams):
pass
class SessionStatusResult(Result):
output: str
method("session.status", params=SessionStatusParams, result=SessionStatusResult,
doc="Rendered /status text for the session.")
class SessionHistoryParams(SessionParams):
pass
class SessionHistoryResult(Result):
count: int
messages: list[TranscriptMessage]
method("session.history", params=SessionHistoryParams, result=SessionHistoryResult,
doc="The durable display transcript (ancestors included, row ids attached).")
class SessionUsageParams(SessionParams):
pass
class SessionUsageResult(Usage):
credits_lines: list[str] | None = None
method("session.usage", params=SessionUsageParams, result=SessionUsageResult,
doc="Token / context / cost counters for the session (+ Nous credit lines when available).")
class SessionContextBreakdownParams(SessionParams):
pass
class ContextCategory(Result):
color: str
id: str
label: str
tokens: int
class ContextFileSource(Result):
"""One row of ``agent.context_file_sources.list_context_file_sources``."""
label: str
path: str
chars: int
est_tokens: int
loaded: bool
status: str
class SessionContextBreakdownResult(Result):
"""``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)
plus the per-file context manifest (empty until the agent exists)."""
categories: list[ContextCategory]
context_max: int
context_percent: int
context_used: int
estimated_total: int
context_estimated: bool
context_source: str
model: str
context_files: list[ContextFileSource] = []
method("session.context_breakdown", params=SessionContextBreakdownParams, result=SessionContextBreakdownResult,
doc="Cursor-style split of the context window by category.")
# ── compression ───────────────────────────────────────────────────────────────────────────────
class CompressionSummary(OpenModel):
"""``agent.manual_compression_feedback.summarize_manual_compression``."""
noop: bool = False
aborted: bool = False
refused_would_grow: bool | None = None
fallback_used: bool | None = None
headline: str = ""
token_line: str = ""
note: str | None = None
class SessionCompressParams(SessionParams):
focus_topic: str | None = None
class SessionCompressResult(Result):
"""In-process: the before/after summary + replacement transcript. Compute host: its result passes
through (hence open) with ``turn_isolation``; a lock held elsewhere answers ``compressed: false``."""
status: str | None = None # compressed | aborted | pending
removed: int | None = None
before_messages: int | None = None
after_messages: int | None = None
before_tokens: int | None = None
after_tokens: int | None = None
summary: CompressionSummary | None = None
usage: Usage | None = None
info: SessionLiveInfo | None = None
messages: list[TranscriptMessage] | None = None
compressed: bool | None = None
lock_held: bool | None = None
message: str | None = None
turn_isolation: bool | None = None
host_ack: dict[str, JsonValue] | None = None
model_config = Result.model_config | {"extra": "allow"}
method("session.compress", params=SessionCompressParams, result=SessionCompressResult,
doc="Manual /compress of an idle session, optionally focused on a topic.")
# ── interrupt / steer / redirect ──────────────────────────────────────────────────────────────
class SessionInterruptParams(SessionParams):
expected_hosted_task_id: str | None = None # only interrupt if this hosted task is the running one
class InterruptStatus(WireEnum):
interrupted = "interrupted"
not_interrupted = "not_interrupted"
class SessionInterruptResult(Result):
status: InterruptStatus
interrupted: bool | None = None
turn_isolation: bool | None = None
method("session.interrupt", params=SessionInterruptParams, result=SessionInterruptResult,
doc="Stop the running turn (and streaming TTS); retires the crash-recovery marker.")
class CorrectionStatus(WireEnum):
queued = "queued"
redirected = "redirected"
rejected = "rejected"
class SessionCorrectionParams(SessionParams):
text: str
class SessionCorrectionResult(Result):
status: CorrectionStatus
text: str
method("session.steer", params=SessionCorrectionParams, result=SessionCorrectionResult,
doc="Inject text into the next tool result without interrupting the turn.")
method("session.redirect", params=SessionCorrectionParams, result=SessionCorrectionResult,
doc="Redirect the active turn (queued for the next turn while the agent is still building).")
# ── spawn trees ───────────────────────────────────────────────────────────────────────────────
class SpawnTreeSaveParams(ProfileParams):
subagents: list[dict[str, JsonValue]]
session_id: str | None = None # stored key; "default" when absent
started_at: float | None = None
finished_at: float | None = None
label: str | None = None
class SpawnTreeSaveResult(Result):
path: str
session_id: str
method("spawn_tree.save", params=SpawnTreeSaveParams, result=SpawnTreeSaveResult,
doc="Persist a finished delegation tree snapshot under the session's spawn-trees dir.")
class SpawnTreeListParams(ProfileParams):
session_id: str | None = None
cross_session: bool = False
limit: int | None = None
class SpawnTreeEntry(OpenModel):
"""Index row (``server._append_spawn_tree_index``) or a legacy file scan."""
path: str
session_id: str | None = None
started_at: float | None = None
finished_at: float | None = None
label: str = ""
count: int = 0
class SpawnTreeListResult(Result):
entries: list[SpawnTreeEntry]
method("spawn_tree.list", params=SpawnTreeListParams, result=SpawnTreeListResult,
doc="Saved spawn-tree snapshots, newest first.")
class SpawnTreeLoadParams(ProfileParams):
path: str
class SpawnTreeLoadResult(Result):
"""The snapshot file as written by ``spawn_tree.save`` (open: the file is the contract)."""
session_id: str | None = None
started_at: float | None = None
finished_at: float | None = None
label: str | None = None
subagents: list[dict[str, JsonValue]] = Field(default_factory=list)
model_config = Result.model_config | {"extra": "allow"}
method("spawn_tree.load", params=SpawnTreeLoadParams, result=SpawnTreeLoadResult,
doc="Read one saved spawn-tree snapshot (path must be under the spawn-trees root).")
# ── terminal / event replay ───────────────────────────────────────────────────────────────────
class TerminalResizeParams(SessionParams):
cols: int | None = None
class TerminalResizeResult(Result):
cols: int
method("terminal.resize", params=TerminalResizeParams, result=TerminalResizeResult,
doc="Record the client's column width for server-side rendering.")
class SessionEventsSinceParams(SessionParams):
last_seen: int | None = None
class SessionEventsSinceResult(Result):
events: list[dict[str, JsonValue]] # recorded event frames' ``params`` objects
latest_seq: int
truncated: bool
count: int
epoch: str
open_requests: list[OpenRequestEntry]
method("session.events.since", params=SessionEventsSinceParams, result=SessionEventsSinceResult,
doc="Replay events after a seq watermark on WS reconnect; truncated means refetch state.")
class SessionEventsStatsParams(ProfileParams):
pass
class SessionEventsStatsResult(Result):
"""``event_replay.replay_stats``."""
sessions: int
events: int
bytes: int
max_per_session: int
max_bytes_per_session: int
max_bytes_process: int
method("session.events.stats", params=SessionEventsStatsParams, result=SessionEventsStatsResult,
doc="Replay-buffer occupancy telemetry (ops/debug).")
# ── one-shot LLM ──────────────────────────────────────────────────────────────────────────────
class LlmOneshotParams(ProfileParams):
"""Needs a ``template`` or ``instructions`` / ``input``; a live ``session_id`` lends its model."""
template: str | None = None
instructions: str | None = None
input: str | None = None
variables: dict[str, JsonValue] | None = None
task: str | None = None
temperature: float | None = None
max_tokens: int | None = None
session_id: str | None = None
class LlmOneshotResult(Result):
text: str
method("llm.oneshot", params=LlmOneshotParams, result=LlmOneshotResult,
doc="Stateless one-shot LLM completion (titles, ideas) on the session's or the task backend.")