feat: slash command parity + skill autocomplete — v0.50.91 (PR #711)
Combines PR #618 (@renheqiang) slash command parity (/retry /undo /stop /title /status /voice) with PR #701 (@franksong2702) skill autocomplete. 1469 tests pass. Closes #460. Co-authored-by: renheqiang <renheqiang@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
56
api/commands.py
Normal file
56
api/commands.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
|
||||
|
||||
This module is the single integration point with hermes_cli.commands.
|
||||
If hermes-agent is unavailable the endpoint degrades to an empty list
|
||||
so the frontend can still load with WEBUI_ONLY commands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commands that are gateway_only in the agent registry -- webui never
|
||||
# wants to expose them (sethome, restart, update etc.) even if a future
|
||||
# agent version drops the gateway_only flag. /commands is the agent's
|
||||
# own command-listing command; webui has its own /help that calls
|
||||
# cmdHelp() locally, so /commands would be redundant and confusing.
|
||||
_NEVER_EXPOSE: frozenset[str] = frozenset({
|
||||
'sethome', 'restart', 'update', 'commands',
|
||||
})
|
||||
|
||||
|
||||
def list_commands(_registry=None) -> list[dict[str, Any]]:
|
||||
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
|
||||
|
||||
Returns empty list if hermes_cli is not installed (graceful
|
||||
degradation -- the frontend has its own fallback minimum set).
|
||||
|
||||
Args:
|
||||
_registry: Optional injected registry for testing. When None
|
||||
(production), imports COMMAND_REGISTRY from hermes_cli.
|
||||
"""
|
||||
if _registry is None:
|
||||
try:
|
||||
from hermes_cli.commands import COMMAND_REGISTRY as _registry
|
||||
except ImportError:
|
||||
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
|
||||
return []
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for cmd in _registry:
|
||||
if cmd.gateway_only:
|
||||
continue
|
||||
if cmd.name in _NEVER_EXPOSE:
|
||||
continue
|
||||
out.append({
|
||||
'name': cmd.name,
|
||||
'description': cmd.description,
|
||||
'category': cmd.category,
|
||||
'aliases': list(cmd.aliases),
|
||||
'args_hint': cmd.args_hint,
|
||||
'subcommands': list(cmd.subcommands),
|
||||
'cli_only': bool(cmd.cli_only),
|
||||
'gateway_only': bool(cmd.gateway_only),
|
||||
})
|
||||
return out
|
||||
@@ -509,6 +509,26 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"session": redact_session_data(sess)})
|
||||
return bad(handler, "Session not found", 404)
|
||||
|
||||
if parsed.path == "/api/session/status":
|
||||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||||
if not sid:
|
||||
return bad(handler, "Missing session_id")
|
||||
try:
|
||||
from api.session_ops import session_status
|
||||
return j(handler, session_status(sid))
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
|
||||
if parsed.path == "/api/session/usage":
|
||||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||||
if not sid:
|
||||
return bad(handler, "Missing session_id")
|
||||
try:
|
||||
from api.session_ops import session_usage
|
||||
return j(handler, session_usage(sid))
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
|
||||
if parsed.path == "/api/sessions":
|
||||
webui_sessions = all_sessions()
|
||||
settings = load_settings()
|
||||
@@ -581,6 +601,10 @@ def handle_get(handler, parsed) -> bool:
|
||||
info = git_info_for_workspace(Path(s.workspace))
|
||||
return j(handler, {"git": info})
|
||||
|
||||
if parsed.path == "/api/commands":
|
||||
from api.commands import list_commands
|
||||
return j(handler, {"commands": list_commands()})
|
||||
|
||||
if parsed.path == "/api/updates/check":
|
||||
settings = load_settings()
|
||||
if not settings.get("check_for_updates", True):
|
||||
@@ -916,6 +940,34 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/session/compress":
|
||||
return _handle_session_compress(handler, body)
|
||||
|
||||
if parsed.path == "/api/session/retry":
|
||||
try:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
from api.session_ops import retry_last
|
||||
result = retry_last(body["session_id"])
|
||||
return j(handler, {"ok": True, **result})
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
except ValueError as e:
|
||||
return j(handler, {"error": str(e)})
|
||||
|
||||
if parsed.path == "/api/session/undo":
|
||||
try:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
from api.session_ops import undo_last
|
||||
result = undo_last(body["session_id"])
|
||||
return j(handler, {"ok": True, **result})
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
except ValueError as e:
|
||||
return j(handler, {"error": str(e)})
|
||||
|
||||
if parsed.path == "/api/chat/start":
|
||||
return _handle_chat_start(handler, body)
|
||||
|
||||
|
||||
151
api/session_ops.py
Normal file
151
api/session_ops.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Session-mutation operations for slash commands (/retry, /undo) and
|
||||
read-only aggregators (/status, /usage). Operates on the webui's own
|
||||
JSON Session store (api/models.py), not on hermes-agent's SQLite.
|
||||
|
||||
Behavior parity reference: gateway/run.py:_handle_*_command in
|
||||
the hermes-agent repo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.config import LOCK
|
||||
from api.models import get_session, SESSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_last(session_id: str) -> dict[str, Any]:
|
||||
"""Truncate the session to before the last user message, return its text.
|
||||
|
||||
Mirrors gateway/run.py:_handle_retry_command. Caller (webui frontend)
|
||||
is expected to put the returned text back in the composer and call
|
||||
send() to resume the conversation -- the agent's gateway calls its own
|
||||
_handle_message; the webui has no equivalent in-process pipeline.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
# get_session() and Session.save() both acquire the module-level LOCK
|
||||
# internally (the latter via _write_session_index()), and LOCK is a
|
||||
# non-reentrant threading.Lock — so they MUST be called outside our
|
||||
# own `with LOCK:` block to avoid self-deadlocking.
|
||||
#
|
||||
# The race we close is the read-modify-write of s.messages: two
|
||||
# concurrent /api/session/retry calls could otherwise both compute the
|
||||
# same last_user_idx from the same history and double-truncate. We
|
||||
# serialize just the in-memory mutation; persistence happens outside
|
||||
# the lock and is naturally last-write-wins on a consistent state.
|
||||
#
|
||||
# Stale-object guard: on a cache miss, two concurrent get_session()
|
||||
# calls can each load and cache a *different* Session instance for the
|
||||
# same session_id (the second store_clobbers the first). Re-bind to
|
||||
# the canonical cached instance inside the lock so the mutation lands
|
||||
# on the object the next reader will see, not a stale parallel copy.
|
||||
s = get_session(session_id) # raises KeyError if missing
|
||||
with LOCK:
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('No previous message to retry.')
|
||||
|
||||
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.save()
|
||||
return {'last_user_text': last_user_text, 'removed_count': removed_count}
|
||||
|
||||
|
||||
def undo_last(session_id: str) -> dict[str, Any]:
|
||||
"""Remove the most recent user message and everything after it.
|
||||
|
||||
Mirrors gateway/run.py:_handle_undo_command. Returns a preview of the
|
||||
removed text so the UI can confirm to the user.
|
||||
|
||||
Raises:
|
||||
KeyError: session not found
|
||||
ValueError: no user message in transcript
|
||||
"""
|
||||
s = get_session(session_id) # acquires LOCK transiently
|
||||
with LOCK:
|
||||
# Stale-object guard — see retry_last for the rationale.
|
||||
s = SESSIONS.get(session_id, s)
|
||||
history = s.messages or []
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get('role') == 'user':
|
||||
last_user_idx = i
|
||||
break
|
||||
if last_user_idx is None:
|
||||
raise ValueError('Nothing to undo.')
|
||||
|
||||
removed_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
|
||||
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
|
||||
return {
|
||||
'removed_count': removed_count,
|
||||
'removed_preview': preview,
|
||||
}
|
||||
|
||||
|
||||
def session_status(session_id: str) -> dict[str, Any]:
|
||||
"""Return a snapshot of session state for /status.
|
||||
|
||||
Webui equivalent of gateway/run.py:_handle_status_command. The agent's
|
||||
"agent_running" comes from `session_key in self._running_agents`; the
|
||||
webui equivalent is whether the session has an active stream
|
||||
(active_stream_id is set).
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
return {
|
||||
'session_id': s.session_id,
|
||||
'title': s.title,
|
||||
'model': s.model,
|
||||
'workspace': s.workspace,
|
||||
'personality': s.personality,
|
||||
'message_count': len(s.messages or []),
|
||||
'created_at': s.created_at,
|
||||
'updated_at': s.updated_at,
|
||||
'agent_running': bool(getattr(s, 'active_stream_id', None)),
|
||||
}
|
||||
|
||||
|
||||
def session_usage(session_id: str) -> dict[str, Any]:
|
||||
"""Return token usage and cost for /usage.
|
||||
|
||||
Mirrors gateway/run.py:_handle_usage_command's basic counters. The
|
||||
agent shows additional fields (rate-limit headroom etc.) that depend
|
||||
on provider API responses we don't have in webui -- those are deferred.
|
||||
"""
|
||||
s = get_session(session_id)
|
||||
inp = int(s.input_tokens or 0)
|
||||
out = int(s.output_tokens or 0)
|
||||
return {
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'total_tokens': inp + out,
|
||||
'estimated_cost': s.estimated_cost,
|
||||
'model': s.model,
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""Flatten message content to plain text. Agent stores either a string
|
||||
or a list of {type, text|...} parts; webui needs the user-typed text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
parts.append(p.get('text', ''))
|
||||
return ' '.join(parts)
|
||||
return str(content)
|
||||
Reference in New Issue
Block a user