mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
After any compression, session.resume (Ink /resume, Desktop's warm re-focus and replay-gap re-snapshot, the one-shot chat view's --resume printout) returned the model's working set: the summary and the last exchange. Earlier turns looked deleted although they are stored. The resume snapshot is now the display history. local_history returns the display lineage (as the REST transcript and the legacy resume do), marks every row outside the current transcript's active set `archived: true`, and keeps every live row, so the rows without the mark are exactly the live transcript. Archived rows fill, newest first, what the live rows leave of a 3 MiB budget, so the reply stays under the clients' 8 MiB frame whenever main's live-only reply does; when older ones are left out the first row says `archived_truncated: true`. attach and the hosted-room history read and budget the lineage off the loop; under the event-stream lock only the live tip is re-read, and a rewind or compaction since the read triggers a re-read. Each logical message shows once: - the store's display dedupe also reads removed rows, and hides a message whose newest copy is removed, so a turn undone in a compression child does not come back from the parent's copy (copies share the display key or message_uid; a merge stands for the prompts it absorbed); - a micro-compaction merge (model_only) stays a live undo target but Ink's display_messages, Desktop hydration and the one-shot printer skip it: its originals are the archived rows they show. An archived row is display-only: - the canonical rewind refuses a target that is not an active row of the current transcript (before, it deactivated the whole live conversation, also reachable from a Desktop edit of an archived row); - the classic and Ink /undo and /retry selectors, the classic rewind_user_turn, /compress and /rollback history, and ACP's replay and token estimate skip it. Desktop's /undo and /retry and the owner's /rollback turn rewind follow in the next commit. Three tests that pinned the tip-only snapshot now assert the old rows are present and archived, and the live rows unchanged. Refs #134889 Refs #140449 (cherry picked from commit 81e4666af9d048e1df89c704a921ff55ee9b2ef6)
236 lines
12 KiB
Python
236 lines
12 KiB
Python
"""Classic CLI slash commands on the shared gateway, beyond the view's own controls.
|
|
|
|
``GatewayChatView.command`` answers the controls it owns (/stop /approve /answer /discard
|
|
/branch /model /compress /yolo /quit). This module adds the session commands main's classic
|
|
CLI ran in-process, mapped onto what the owner serves -- the mapping Ink uses
|
|
(``ui-tui/src/app/slash/canonicalSessionCommands.ts``):
|
|
|
|
- ``/title <name>`` -> ``session.mutate rename``; bare ``/title`` -> the owner's report
|
|
- ``/undo [N]`` / ``/retry`` -> ``session.mutate rewind`` to the Nth-newest user turn
|
|
(``/undo`` puts the removed text back in the composer, ``/retry`` resubmits it)
|
|
- ``/new [title]`` / ``/reset`` -> ``session.create`` with this session's frozen launch request
|
|
- ``/usage`` -> the owner's session report plus this view's last committed turn
|
|
- ``/tools`` -> the toolsets frozen at launch
|
|
- every other registry command -> ``slash.exec``: the owner runs its reviewed reads and
|
|
refuses the rest, which prints where the command stands (the parity table below).
|
|
"""
|
|
import contextlib
|
|
import sys
|
|
import uuid
|
|
|
|
from hermes_cli.gateway_client import GatewayClientError
|
|
|
|
PARITY_DOC = "https://hermes-agent.nousresearch.com/docs/developer-guide/gateway-command-parity"
|
|
|
|
# Registry command (canonical name) -> how the classic view answers it itself. Every other
|
|
# registry command goes to the owner through ``slash.exec``. Read by the parity inventory
|
|
# (scripts/gen_gateway_command_parity.py).
|
|
VIEW_ROUTES = {
|
|
"quit": "detach", "stop": "session.interrupt", "approve": "approval.respond (prompt id)",
|
|
"branch": "session.mutate branch", "model": "session.mutate model", "compress": "session.mutate compress",
|
|
"yolo": "config.set yolo", "help": "local",
|
|
"title": "session.mutate rename", "undo": "session.mutate rewind", "retry": "session.mutate rewind + resubmit",
|
|
"new": "session.create", "clear": "session.create", "usage": "slash.exec + last-turn receipt",
|
|
"tools": "session.info launch toolsets",
|
|
}
|
|
|
|
|
|
def refusal(name):
|
|
return f"/{name} is not available on the shared gateway yet. Parity table: {PARITY_DOC}"
|
|
|
|
|
|
def _user_turns(snapshot):
|
|
"""Human-authored live user rows (``row_id`` = durable ``messages.id``), oldest first: no display-only
|
|
kind other than a typed /steer, never a compaction handoff carrier, never a row a compression
|
|
archived (shown in the snapshot's history, not a turn to rewind to)."""
|
|
from agent.context_compressor import is_user_originated_turn
|
|
return [row for row in snapshot.get("messages", [])
|
|
if type(row.get("row_id")) is int and row.get("display_kind") in (None, "", "steer")
|
|
and not row.get("archived") and is_user_originated_turn(row)]
|
|
|
|
|
|
def _rewind_to(turns_back, *, retry=False):
|
|
def payload(snapshot):
|
|
turns = _user_turns(snapshot)
|
|
if len(turns) < turns_back:
|
|
return None
|
|
target = turns[-turns_back]
|
|
if retry:
|
|
# Main's retry_last order: a turn that cannot be resent losslessly as text (an image
|
|
# turn) is refused on the CAS snapshot, before session.mutate archives it.
|
|
from agent.context_compressor import retryable_user_text, user_originated_turn_view
|
|
try:
|
|
retryable_user_text((user_originated_turn_view(target) or {}).get("content"))
|
|
except ValueError as exc:
|
|
raise GatewayClientError(f"/retry: {exc}") from exc
|
|
return {"target_message_id": target["row_id"]}
|
|
return payload
|
|
|
|
|
|
async def _mutate(view, operation, payload):
|
|
result = await view.mutations.apply(view.client, view.session_id, operation, payload)
|
|
view.mutations.acknowledge(view.session_id, operation, payload)
|
|
return result
|
|
|
|
|
|
async def _rewind(view, turns_back, *, retry=False):
|
|
"""The rewound user turn's text (``/retry``: its lossless resend text), or None when there was
|
|
nothing to rewind."""
|
|
payload = _rewind_to(turns_back, retry=retry)
|
|
result = await view.mutations.apply(view.client, view.session_id, "rewind", payload)
|
|
if result.get("status") == "nothing" or not result.get("rewound_count"):
|
|
view.mutations.acknowledge(view.session_id, "rewind", payload)
|
|
return None
|
|
# Retained until the refresh lands: a repeat after a lost refresh replays this rewind instead of
|
|
# deriving a new target from the already-shortened transcript (which would drop the turn before).
|
|
snapshot = await view.client.rpc("session.resume", session_id=view.session_id)
|
|
view.mutations.acknowledge(view.session_id, "rewind", payload)
|
|
view.generation = snapshot["execution_generation"]
|
|
content = (result.get("target_message") or {}).get("content")
|
|
if retry:
|
|
from agent.context_compressor import retryable_user_text
|
|
with contextlib.suppress(ValueError):
|
|
return retryable_user_text(content), result["rewound_count"]
|
|
return content if isinstance(content, str) else "", result["rewound_count"]
|
|
|
|
|
|
async def _title(view, rest):
|
|
if not rest:
|
|
return await forward(view, "title", "")
|
|
result = await _mutate(view, "rename", {"title": rest})
|
|
print(f"Session title set: {result.get('title') or rest}")
|
|
|
|
|
|
async def _undo(view, rest):
|
|
from agent.i18n import t
|
|
if rest and not (rest.isdigit() and int(rest) > 0):
|
|
raise GatewayClientError("Usage: /undo [N]")
|
|
turns = int(rest or 1)
|
|
rewound = await _rewind(view, turns)
|
|
if rewound is None:
|
|
print(t("cli.session.undo_no_user_message"))
|
|
return
|
|
text, count = rewound
|
|
print(f"Undid {turns} turn{'s' if turns != 1 else ''} ({count} message(s)); the removed message is in the composer.")
|
|
view.prefill = text
|
|
|
|
|
|
async def _retry(view, rest):
|
|
from agent.i18n import t
|
|
rewound = await _rewind(view, 1, retry=True)
|
|
if rewound is None or not rewound[0].strip():
|
|
print(t("cli.session.retry_no_user_message"))
|
|
return
|
|
print(f"\n{'─' * 40}\n● {rewound[0]}", flush=True)
|
|
await view.submit(rewound[0])
|
|
|
|
|
|
async def _new(view, rest):
|
|
"""A fresh session with this one's frozen launch request (model, toolsets, cwd, ...); the old
|
|
session stays resumable by its id. A title is a separate rename so an existing title is a
|
|
conflict, never a silent attach to that other session."""
|
|
from agent.i18n import t
|
|
info = await view.client.rpc("session.info", session_id=view.session_id)
|
|
accepted = set((await view.client.rpc("runtime.describe")).get("session_create", {}).get("parameters", []))
|
|
request = {key: value for key, value in (info.get("launch_request") or {}).items()
|
|
if key in accepted and key not in {"request_id", "title"}}
|
|
if info.get("cwd") and "cwd" in accepted:
|
|
request["cwd"] = info["cwd"]
|
|
# The frozen request never holds the launch key; the one this terminal supplied rides along.
|
|
if getattr(view, "launch_api_key", None) and "api_key" in accepted:
|
|
request["api_key"] = view.launch_api_key
|
|
from hermes_cli.gateway_chat import client_env_policy
|
|
request.update(client_env_policy(accepted)) # never in launch_request: this shell's, sent afresh
|
|
snapshot = await view.client.rpc("session.create", request_id=uuid.uuid4().hex, **request)
|
|
await view.adopt(snapshot)
|
|
print(t("cli.session.new_session"))
|
|
print(f"Session: {view.session_id}", file=sys.stderr, flush=True)
|
|
if rest:
|
|
await _title(view, rest)
|
|
|
|
|
|
async def _usage(view, rest):
|
|
if rest: # `/usage reset` redeems a credit: the owner decides (and refuses it for local sessions)
|
|
return await forward(view, "usage", rest)
|
|
await forward(view, "usage", "")
|
|
admission = next(reversed(view.completions), None)
|
|
if admission is None:
|
|
return
|
|
receipt = await view.client.rpc("prompt.receipt", session_id=view.session_id,
|
|
admission_id=admission, include_result=True)
|
|
result = receipt.get("result") or {}
|
|
if not isinstance(result, dict) or not result:
|
|
return
|
|
lines = ["", "Last turn:", f" Model: {result.get('model') or view.model}"]
|
|
for label, key in (("Input tokens", "input_tokens"), ("Output tokens", "output_tokens"),
|
|
("Total tokens", "total_tokens"), ("API calls", "api_calls")):
|
|
lines.append(f" {label}: {int(result.get(key) or 0):,}")
|
|
if isinstance(result.get("estimated_cost_usd"), (int, float)):
|
|
lines.append(f" Cost: ${result['estimated_cost_usd']:.4f}")
|
|
print("\n".join(lines))
|
|
|
|
|
|
async def _tools(view, rest):
|
|
info = await view.client.rpc("session.info", session_id=view.session_id)
|
|
toolsets = (info.get("launch_request") or {}).get("toolsets")
|
|
if isinstance(toolsets, list) and toolsets:
|
|
print("Toolsets frozen at launch: " + ", ".join(map(str, toolsets)))
|
|
else:
|
|
print("This session runs the profile's CLI toolsets as they were at launch (`hermes tools list`).")
|
|
print("Changing toolsets mid-session (/tools enable|disable) " + refusal("tools").split(" ", 1)[1])
|
|
|
|
|
|
async def forward(view, name, rest):
|
|
"""Run a registry command (or a skill) on the owner; its refusal names where the command stands."""
|
|
from hermes_cli.gateway_client import GatewayRPCError
|
|
try:
|
|
reply = await view.client.rpc("slash.exec", session_id=view.session_id,
|
|
command=name + (" " + rest if rest else ""))
|
|
except GatewayRPCError as exc:
|
|
if str(exc) != "unsupported_command":
|
|
raise
|
|
from hermes_cli.commands import resolve_command
|
|
print(refusal(name) if resolve_command(name) else f"Unknown command: /{name} (use /help)")
|
|
return
|
|
if reply.get("type") in ("skill", "send"):
|
|
# A prompt directive is the client's durable submit (same contract as Ink and Desktop):
|
|
# a skill body, or the turn a command built (/plan, /goal's kickoff...) after its notice.
|
|
if reply.get("notice"):
|
|
print(reply["notice"])
|
|
print(f"\n{'─' * 40}\n● {reply.get('display') or '/' + name}", flush=True)
|
|
await view.submit(reply["message"])
|
|
else:
|
|
print(reply.get("output") or f"/{name}: no output")
|
|
|
|
|
|
_VIEW_CONTROLS = frozenset({"quit", "stop", "approve", "branch", "model", "compress", "yolo", "help"})
|
|
# /clear is main's "new session + clear screen"; the gateway view keeps scrollback and starts fresh.
|
|
_HANDLERS = {"title": _title, "undo": _undo, "retry": _retry, "new": _new, "clear": _new, "usage": _usage,
|
|
"tools": _tools}
|
|
|
|
|
|
async def run_command(view, command, rest):
|
|
"""Any slash command the view's own controls did not take."""
|
|
from hermes_cli.commands import resolve_command
|
|
name = command.lstrip("/")
|
|
definition = resolve_command(name)
|
|
canonical = definition.name if definition else name
|
|
if canonical in _VIEW_CONTROLS and canonical != name:
|
|
# An alias of a control the view owns (/fork, /compact, /exit): answer it as the canonical.
|
|
return await view.command(f"/{canonical} {rest}".rstrip())
|
|
handler = _HANDLERS.get(canonical)
|
|
if handler is not None:
|
|
await handler(view, rest.strip())
|
|
else:
|
|
await forward(view, canonical, rest.strip())
|
|
return True
|
|
|
|
|
|
def help_text():
|
|
from gateway.session_commands import _READ_COMMANDS
|
|
reports = " ".join(f"/{name}" for name in sorted(_READ_COMMANDS - {"help"}))
|
|
return ("/stop, /approve <id> <choice>, /answer <id> <text>, /discard <admission_id> (turn lost during "
|
|
"restart), /yolo [on|off], /quit (detach). /branch [title], /model <model> [--provider name], "
|
|
"/compress [here [N] | <focus>] [--preview]. /title [name], /undo [N], /retry, /new [title] "
|
|
f"(/reset), /usage, /tools.\nReports run by the gateway: {reports}.\nParity table: {PARITY_DOC}")
|