hermes-agent/gateway/relay/command_manifest.py
Ben Barclay 40eebc7d70
feat(relay): Phase 4 thread lifecycle — handoff threads, semantic renames, reply_to context, hello command manifest (#71624)
- RelayAdapter.create_handoff_thread → one op-gated thread_create op
  (Discord channel thread / Telegram forum topic / Slack named seed root);
  None fallback contract preserved for the handoff watcher.
- RelayAdapter.rename_thread → thread_rename with only_if_current_name
  no-clobber guard on the wire (connector enforces; Telegram guarded
  renames fail safe). The native semantic-rename lane
  (_is_discord_auto_thread_lane) lights over the relay via the
  connector-stamped auto_thread_created/auto_thread_initial_name markers
  parsed onto SessionSource in _event_from_wire.
- reply_to {text, author, is_own} wire parse onto the SAME MessageEvent
  reply-context fields native adapters populate.
- gateway/relay/command_manifest.py: the gateway-declared slash-command
  manifest (native Discord tree mirror) sent on the DISCORD hello; the
  connector reconciles Discord's global registration (additive field,
  older connectors ignore).
- Contract doc §OutboundAction ops + Phase 4 semantics sections.
- 12 new tests (tests/gateway/relay/test_relay_threads.py); relay suite
  266 passed.
2026-07-26 10:26:20 +10:00

145 lines
5.8 KiB
Python

"""Gateway-declared slash-command manifest for the relay lane (Phase 4).
The native Discord adapter registers its slash commands directly on the
Discord command tree (`_register_slash_commands`,
plugins/platforms/discord/adapter.py) — it holds the bot token. Over the
relay the CONNECTOR holds the token, so the gateway DECLARES the same
command set on its `hello` frame (`command_manifest`) and the connector
reconciles Discord's global application-command registration against it
(gateway-gateway `DiscordCommandRegistrar`: GET → diff → bulk PUT,
idempotent, best-effort).
This module is that declaration: the single source of truth for what the
relay lane advertises. It MIRRORS the native tree — same names, same
descriptions — so a user moving between a native-Discord deployment and a
hosted/relay one sees the same command palette. Interactions come back over
the passthrough plane and are normalized by
RelayAdapter._discord_interaction_to_event into the same "/name args"
COMMAND events the dispatcher already routes, so declaring a command here
requires NO new handler — the dispatcher's existing slash surface is the
handler.
Wire shape (per entry): {name, description, options?} where options rows are
Discord option objects passed through verbatim. Names must satisfy
Discord's CHAT_INPUT rules ([a-z0-9_-]{1,32}); the connector drops invalid
entries (fail-open per entry, never the whole manifest).
"""
from __future__ import annotations
from typing import Any, Dict, List
# Discord option type 3 = STRING.
_STR = 3
def _opt(name: str, description: str, *, choices: List[str] | None = None) -> Dict[str, Any]:
row: Dict[str, Any] = {
"type": _STR,
"name": name,
"description": description,
"required": False,
}
if choices:
row["choices"] = [{"name": c, "value": c} for c in choices]
return row
def build_relay_command_manifest() -> List[Dict[str, Any]]:
"""The relay lane's Discord slash-command manifest (native-tree mirror)."""
return [
{"name": "new", "description": "Start a new conversation"},
{"name": "reset", "description": "Reset your Hermes session"},
{
"name": "model",
"description": "Show or change the model",
"options": [_opt("name", "Model name. Leave empty to see current.")],
},
{
"name": "reasoning",
"description": "Show/change reasoning effort, or toggle showing it",
"options": [
_opt(
"effort",
"Level, reset, or show/hide. Leave empty to see current.",
choices=[
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
"reset",
"show",
"hide",
],
)
],
},
{
"name": "personality",
"description": "Set a personality",
"options": [_opt("name", "Personality name. Leave empty to list.")],
},
{"name": "retry", "description": "Retry your last message"},
{"name": "undo", "description": "Remove the last exchange"},
{"name": "status", "description": "Show Hermes session status"},
{"name": "sethome", "description": "Set this chat as the home channel"},
{"name": "stop", "description": "Stop the running Hermes agent"},
{
"name": "steer",
"description": "Inject a message after the next tool call (no interrupt)",
"options": [_opt("text", "What to tell the agent")],
},
{"name": "compress", "description": "Compress conversation context"},
{
"name": "title",
"description": "Set or show the session title",
"options": [_opt("text", "New title. Leave empty to show.")],
},
{
"name": "resume",
"description": "Resume a previously-named session",
"options": [_opt("name", "Session title or id")],
},
{"name": "usage", "description": "Show token usage for this session"},
{"name": "help", "description": "Show available commands"},
{"name": "insights", "description": "Show usage insights and analytics"},
{"name": "reload-mcp", "description": "Reload MCP servers from config"},
{
"name": "reload-skills",
"description": "Re-scan skills for new or removed entries",
},
{"name": "voice", "description": "Toggle voice reply mode"},
{"name": "update", "description": "Update Hermes Agent to the latest version"},
{"name": "restart", "description": "Gracefully restart the Hermes gateway"},
{
"name": "approve",
"description": "Approve a pending dangerous command",
"options": [
_opt("scope", "Approval scope", choices=["once", "session", "always", "all"])
],
},
{
"name": "deny",
"description": "Deny a pending dangerous command",
"options": [_opt("reason", "Why (relayed to the agent)")],
},
{
"name": "thread",
"description": "Create a new thread and start a Hermes session in it",
"options": [_opt("name", "Thread name")],
},
{
"name": "queue",
"description": "Queue a prompt for the next turn (doesn't interrupt)",
"options": [_opt("text", "The prompt to queue")],
},
{
"name": "background",
"description": "Run a prompt in the background",
"options": [_opt("text", "The prompt to run")],
},
]