mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
PluginContext.ask_choice(question, choices) -> str | None lets a sync
slash-command handler ask the user who ran it to pick one option and get
the picked value back. Choices are strings or {value, label, description}.
Each surface binds an asker around the handler call
(hermes_cli/plugin_choices.py), and every asker is that surface's
existing clarify prompt. No surface gets a new card type or wire shape:
- messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify
plus the clarify tool's own delivery (_clarify_send_then_wait, text
fallback, card retire). Telegram/Discord/Slack show buttons, other
platforms a numbered list. The typed reply reaches the clarify
text-intercept while the command still holds the chat.
- local sessions on the gateway (classic CLI, Ink TUI, Desktop): the
`clarify` server request to the session's viewers. Two fixes make the
answer arrive while slash.exec is still waiting for the handler:
* tui_gateway/ws.py: a viewer's answer to an open server request (a
response frame, `clarify.lock`) is settled at once instead of queuing
behind that socket's running slash.exec.
* hermes_cli/gateway_plugin_commands.py: the classic CLI answers the
session's `clarify` requests with its clarify panel while it waits for
slash.exec. No turn pump reads the session's frames between turns.
- in-process classic CLI and the legacy `hermes serve` backend: their
clarify panel / `clarify` server request.
Returns None when nothing can ask (hooks, cron, async handlers on the
event loop), when the user cancels or answers with something that is not a
choice, or when the clarify timeout passes. The handler then prints its
text usage.
79 lines
4.0 KiB
Python
79 lines
4.0 KiB
Python
"""The gateway's askers for ``PluginContext.ask_choice`` (``hermes_cli/plugin_choices.py``).
|
|
|
|
Both reuse the clarify tool's own prompt, so every surface renders a plugin command's choice the
|
|
way it renders the agent's questions:
|
|
|
|
* a messaging chat gets ``adapter.send_clarify``: buttons where the adapter has them (Telegram,
|
|
Discord, Slack), the base numbered list elsewhere. A tap resolves it through
|
|
``resolve_gateway_clarify``; a typed reply reaches the clarify text-intercept even though the
|
|
command still holds the chat (the adapter's clarify bypass), and an undeliverable card falls back
|
|
to the numbered text, as for the clarify tool;
|
|
* a local session (classic CLI, Ink TUI, Desktop) gets the ``clarify`` server request on its
|
|
viewers' sockets, answered by their clarify panel.
|
|
|
|
The handler runs on an executor thread (``_hm_run_plugin_handler``), so blocking here is safe; the
|
|
wait follows the clarify timeout.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from contextlib import nullcontext
|
|
from typing import Optional
|
|
|
|
from gateway.config import Platform
|
|
|
|
|
|
def chat_choices(runner, source, session_key: str):
|
|
"""Bind the messaging asker for a command *source* sent, unless the caller bound one already (a
|
|
local viewer's ``slash.exec``) or the source is a local session (it has no chat to post in)."""
|
|
from hermes_cli.plugin_choices import asker_bound, bind_asker
|
|
if asker_bound() or source.platform == Platform.LOCAL:
|
|
return nullcontext()
|
|
loop = asyncio.get_running_loop()
|
|
return bind_asker(lambda question, labels: _ask_chat(runner, source, session_key, loop, question, labels))
|
|
|
|
|
|
def _ask_chat(runner, source, session_key, loop, question, labels) -> Optional[str]:
|
|
from gateway.run_turn_runner_clarify_delivery import _clarify_send_then_wait, text_fallback_coro
|
|
from tools import clarify_gateway
|
|
adapter = runner._delivery_adapter_for(source)
|
|
if adapter is None:
|
|
return None
|
|
clarify_id = uuid.uuid4().hex[:10]
|
|
send = dict(chat_id=source.chat_id, question=question, choices=labels, clarify_id=clarify_id,
|
|
session_key=session_key, metadata=runner._thread_metadata_for_source(source))
|
|
clarify_gateway.register(clarify_id, session_key, question, labels)
|
|
|
|
def fallback():
|
|
coro = text_fallback_coro(adapter, **send)
|
|
return None if coro is None else asyncio.run_coroutine_threadsafe(coro, loop)
|
|
card = asyncio.run_coroutine_threadsafe(adapter.send_clarify(**send), loop)
|
|
response, answered = _clarify_send_then_wait(card, clarify_id=clarify_id, session_key=session_key,
|
|
clarify_mod=clarify_gateway, fallback=fallback)
|
|
if answered:
|
|
return response
|
|
retire = getattr(type(adapter), "retire_clarify_card", None)
|
|
if callable(retire): # a persistent card must stop looking answerable
|
|
from gateway.run_turn_runner import _clarify_expired_notice
|
|
asyncio.run_coroutine_threadsafe(retire(adapter, clarify_id, _clarify_expired_notice()), loop)
|
|
return None
|
|
|
|
|
|
def viewer_choices(live, session_id: str):
|
|
"""Bind the local-session asker: the ``clarify`` server request to *live*'s attached viewers."""
|
|
from hermes_cli.plugin_choices import bind_asker
|
|
return bind_asker(lambda question, labels: _ask_viewers(live, session_id, question, labels))
|
|
|
|
|
|
def _ask_viewers(live, session_id, question, labels) -> Optional[str]:
|
|
from gateway.session_server_requests import ask_viewer_result
|
|
from tools.clarify_gateway import get_clarify_timeout
|
|
timeout = get_clarify_timeout()
|
|
result = ask_viewer_result(live.event_stream, session_id, "clarify",
|
|
{"questions": [{"qid": "q0", "question": question, "choices": labels,
|
|
"multi_select": False}]},
|
|
timeout=timeout if timeout > 0 else None, qids=["q0"])
|
|
answer = ((result or {}).get("answers") or {}).get("q0")
|
|
return answer if isinstance(answer, str) else None
|