mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(gateway): inline choice pickers for /reasoning and /fast (Telegram, Discord, Matrix) (#65799)
Bare /reasoning and /fast now render a native one-tap picker on picker-capable platforms, with automatic fallback to the text status card everywhere else — parity with the /model picker UX. - gateway/slash_commands.py: generic send_choice_picker capability gate (detected on the adapter type, like send_model_picker); selection and typed arguments flow through one shared application path so they can never diverge; choices built from VALID_REASONING_EFFORTS so future levels appear automatically - telegram: flat inline-keyboard picker (cp:<idx> callbacks), authorized users only (same gate as approval buttons) - discord: ChoicePickerView select menu, auth mirrors ExecApprovalView, 2-minute timeout with expiry edit - matrix: reaction-based picker; reaction set extended to 12 slots to fit the full effort ladder + subcommands - locales: picker_title + choice labels in all 16 languages - docs: ADDING_A_PLATFORM.md capability table Closes #61110.
This commit is contained in:
parent
659d1123c4
commit
bd37ff9138
22 changed files with 970 additions and 104 deletions
|
|
@ -5961,6 +5961,54 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
logger.warning("[%s] send_model_picker failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a flat select-menu choice picker (one selection → one value).
|
||||
|
||||
Generic single-level companion to ``send_model_picker`` used by
|
||||
`/reasoning`, `/fast`, and any future finite-choice command. Each
|
||||
choice dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
target_id = chat_id
|
||||
if metadata and metadata.get("thread_id"):
|
||||
target_id = metadata["thread_id"]
|
||||
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚙ " + (title.splitlines()[0] if title else "Choose an option"),
|
||||
description="\n".join(title.splitlines()[1:]) or None,
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
view = ChoicePickerView(
|
||||
choices=choices,
|
||||
on_choice_selected=on_choice_selected,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
allowed_role_ids=self._allowed_role_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
view._message = msg # store for on_timeout expiration editing
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_choice_picker failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
|
||||
"""Return the parent channel ID for a Discord thread-like channel, if present."""
|
||||
parent = getattr(channel, "parent", None)
|
||||
|
|
@ -6850,7 +6898,7 @@ def _define_discord_view_classes() -> None:
|
|||
lazy install sets DISCORD_AVAILABLE=True but leaves the classes
|
||||
undefined, causing NameError on the first button interaction.
|
||||
"""
|
||||
global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView
|
||||
global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView, ChoicePickerView
|
||||
|
||||
class ExecApprovalView(discord.ui.View):
|
||||
"""
|
||||
|
|
@ -7547,6 +7595,97 @@ def _define_discord_view_classes() -> None:
|
|||
pass
|
||||
|
||||
|
||||
class ChoicePickerView(discord.ui.View):
|
||||
"""Flat select-menu view for finite-choice commands (/reasoning, /fast).
|
||||
|
||||
One dropdown, one selection, done — the generic single-level companion
|
||||
to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``.
|
||||
Times out after 2 minutes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices: list,
|
||||
on_choice_selected,
|
||||
allowed_user_ids: set,
|
||||
allowed_role_ids: Optional[set] = None,
|
||||
):
|
||||
super().__init__(timeout=120)
|
||||
self.choices = list(choices)[:25] # Discord select cap
|
||||
self.on_choice_selected = on_choice_selected
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
self._message = None
|
||||
|
||||
options = []
|
||||
for choice in self.choices:
|
||||
label = str(choice.get("label") or choice.get("value") or "")
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=_truncate_discord_component_text(
|
||||
label, _DISCORD_SELECT_FIELD_LIMIT
|
||||
),
|
||||
value=str(choice.get("value") or ""),
|
||||
description="current" if choice.get("is_current") else None,
|
||||
)
|
||||
)
|
||||
select = discord.ui.Select(
|
||||
placeholder="Choose an option...",
|
||||
options=options,
|
||||
)
|
||||
select.callback = self._on_select
|
||||
self.add_item(select)
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
return _component_check_auth(
|
||||
interaction, self.allowed_user_ids, self.allowed_role_ids,
|
||||
)
|
||||
|
||||
async def _on_select(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"⛔ You are not authorized to change this setting.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
if self.resolved:
|
||||
await interaction.response.defer()
|
||||
return
|
||||
self.resolved = True
|
||||
|
||||
value = interaction.data.get("values", [""])[0]
|
||||
try:
|
||||
result_text = await self.on_choice_selected(
|
||||
str(interaction.channel_id), value
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Choice picker selection failed: %s", exc)
|
||||
result_text = f"Error applying selection: {exc}"
|
||||
|
||||
embed = discord.Embed(
|
||||
description=result_text,
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
self.clear_items()
|
||||
self.stop()
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
async def on_timeout(self):
|
||||
if self.resolved:
|
||||
return
|
||||
msg = self._message
|
||||
if msg is not None:
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
description="⏱ Selection expired — no change made.",
|
||||
color=discord.Color.greyple(),
|
||||
)
|
||||
self.clear_items()
|
||||
await msg.edit(embed=embed, view=self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class ClarifyChoiceView(discord.ui.View):
|
||||
"""Interactive button view for the clarify tool's multiple-choice prompts.
|
||||
|
||||
|
|
|
|||
|
|
@ -337,6 +337,21 @@ class _MatrixModelPickerPrompt:
|
|||
bot_reaction_events: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixChoicePickerPrompt:
|
||||
"""Tracks a pending Matrix reaction-based choice picker (/reasoning, /fast)."""
|
||||
|
||||
chat_id: str
|
||||
message_id: str
|
||||
session_key: str
|
||||
choices: dict[str, str] # emoji -> value
|
||||
on_choice_selected: Any
|
||||
requester_user_id: str | None = None
|
||||
expires_at: float | None = None
|
||||
resolved: bool = False
|
||||
bot_reaction_events: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
|
@ -386,6 +401,14 @@ _MATRIX_MODEL_PICKER_REACTIONS = (
|
|||
"\U0001f51f",
|
||||
)
|
||||
|
||||
# Choice pickers (/reasoning, /fast) can need more than 10 slots
|
||||
# (8 effort levels + none + reset/show/hide = 12), so extend the keycap
|
||||
# set with lettered squares.
|
||||
_MATRIX_CHOICE_PICKER_REACTIONS = _MATRIX_MODEL_PICKER_REACTIONS + (
|
||||
"\U0001f170\ufe0f", # 🅰️
|
||||
"\U0001f171\ufe0f", # 🅱️
|
||||
)
|
||||
|
||||
_MATRIX_CAPABILITIES: Dict[str, str] = {
|
||||
"text": "yes",
|
||||
"threads": "yes",
|
||||
|
|
@ -954,6 +977,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
except ValueError:
|
||||
self._approval_timeout_seconds = 300
|
||||
self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {}
|
||||
self._choice_picker_prompts_by_event: Dict[str, _MatrixChoicePickerPrompt] = {}
|
||||
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||
self._allowed_user_ids: Set[str] = {
|
||||
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||
|
|
@ -2136,6 +2160,66 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
|
||||
return result
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Matrix reaction-based choice picker (/reasoning, /fast).
|
||||
|
||||
Generic single-level companion to ``send_model_picker``. Each choice
|
||||
dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
emoji_choices: dict[str, str] = {}
|
||||
lines = [title, ""]
|
||||
for i, choice in enumerate(choices):
|
||||
if i >= len(_MATRIX_CHOICE_PICKER_REACTIONS):
|
||||
break
|
||||
emoji = _MATRIX_CHOICE_PICKER_REACTIONS[i]
|
||||
value = str(choice.get("value") or "")
|
||||
label = str(choice.get("label") or value)
|
||||
if choice.get("is_current"):
|
||||
label = f"{label} ← current"
|
||||
emoji_choices[emoji] = value
|
||||
lines.append(f"{emoji} {label}")
|
||||
|
||||
if not emoji_choices:
|
||||
return SendResult(success=False, error="No choices")
|
||||
|
||||
lines.append("")
|
||||
lines.append("React to choose.")
|
||||
result = await self.send(chat_id, "\n".join(lines), metadata=metadata)
|
||||
if not result.success or not result.message_id:
|
||||
return result
|
||||
|
||||
prompt = _MatrixChoicePickerPrompt(
|
||||
chat_id=chat_id,
|
||||
message_id=result.message_id,
|
||||
session_key=session_key,
|
||||
choices=emoji_choices,
|
||||
on_choice_selected=on_choice_selected,
|
||||
requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None,
|
||||
expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
|
||||
)
|
||||
self._choice_picker_prompts_by_event[result.message_id] = prompt
|
||||
|
||||
for emoji in emoji_choices:
|
||||
try:
|
||||
reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||
if reaction_event_id:
|
||||
prompt.bot_reaction_events[emoji] = str(reaction_event_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to add choice picker reaction %s: %s", emoji, exc)
|
||||
|
||||
return result
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||
# Strip image markdown; media is uploaded separately.
|
||||
|
|
@ -3328,6 +3412,40 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return
|
||||
|
||||
choice_prompt = self._choice_picker_prompts_by_event.get(reacts_to)
|
||||
if choice_prompt and not choice_prompt.resolved:
|
||||
if room_id != choice_prompt.chat_id:
|
||||
return
|
||||
if self._matrix_prompt_expired(choice_prompt):
|
||||
self._choice_picker_prompts_by_event.pop(reacts_to, None)
|
||||
return
|
||||
if not await self._validate_matrix_prompt_reactor(
|
||||
room_id, reacts_to, sender, choice_prompt, "choice picker"
|
||||
):
|
||||
return
|
||||
value = choice_prompt.choices.get(key)
|
||||
if value is None:
|
||||
await self._send_invalid_reaction_feedback(
|
||||
room_id,
|
||||
reacts_to,
|
||||
"That reaction is not one of the available choices.",
|
||||
)
|
||||
return
|
||||
choice_prompt.resolved = True
|
||||
self._choice_picker_prompts_by_event.pop(reacts_to, None)
|
||||
try:
|
||||
confirmation = await choice_prompt.on_choice_selected(room_id, value)
|
||||
if confirmation:
|
||||
await self.send(room_id, confirmation, reply_to=reacts_to)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to apply choice from Matrix reaction: %s", exc)
|
||||
await self.send(
|
||||
room_id,
|
||||
f"Failed to apply selection: {exc}",
|
||||
reply_to=reacts_to,
|
||||
)
|
||||
return
|
||||
|
||||
def _matrix_prompt_expired(self, prompt: Any) -> bool:
|
||||
expires_at = getattr(prompt, "expires_at", None)
|
||||
return expires_at is not None and time.monotonic() > float(expires_at)
|
||||
|
|
|
|||
|
|
@ -778,6 +778,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
# Interactive model picker state per chat
|
||||
self._model_picker_state: Dict[str, dict] = {}
|
||||
self._choice_picker_state: Dict[str, dict] = {}
|
||||
# Approval button state: message_id → session_key
|
||||
self._approval_state: Dict[int, str] = {}
|
||||
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
|
||||
|
|
@ -5213,6 +5214,128 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
||||
|
||||
_PROVIDER_PAGE_SIZE = 10
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a flat inline-keyboard choice picker (one tap → one value).
|
||||
|
||||
Generic single-level companion to ``send_model_picker`` used by
|
||||
`/reasoning`, `/fast`, and any future finite-choice command. Each
|
||||
choice dict: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
buttons = []
|
||||
for i, choice in enumerate(choices):
|
||||
label = str(choice.get("label") or choice.get("value") or "")
|
||||
if choice.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"cp:{i}")
|
||||
)
|
||||
if not buttons:
|
||||
return SendResult(success=False, error="No choices")
|
||||
# Two buttons per row keeps labels readable on mobile.
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
[buttons[i:i + 2] for i in range(0, len(buttons), 2)]
|
||||
)
|
||||
|
||||
thread_id = metadata.get("thread_id") if metadata else None
|
||||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||||
msg = await self._send_message_with_thread_fallback(
|
||||
chat_id=normalize_telegram_chat_id(chat_id),
|
||||
text=self.format_message(title),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_id,
|
||||
**self._thread_kwargs_for_send(
|
||||
chat_id,
|
||||
thread_id,
|
||||
metadata,
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_mode=self._reply_to_mode
|
||||
),
|
||||
**self._link_preview_kwargs(),
|
||||
)
|
||||
|
||||
self._choice_picker_state[str(chat_id)] = {
|
||||
"msg_id": msg.message_id,
|
||||
"choices": choices,
|
||||
"session_key": session_key,
|
||||
"on_choice_selected": on_choice_selected,
|
||||
}
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_choice_picker failed: %s", self.name, _redact_telegram_error_text(e))
|
||||
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
||||
|
||||
async def _handle_choice_picker_callback(
|
||||
self, query, data: str, chat_id: str
|
||||
) -> None:
|
||||
"""Handle choice picker button taps (cp:<index>)."""
|
||||
state = self._choice_picker_state.get(chat_id)
|
||||
if not state:
|
||||
await query.answer(text="Picker expired — run the command again.")
|
||||
return
|
||||
|
||||
# Same authorization gate as approval buttons: unauthorized users in a
|
||||
# shared group must not flip session/config state via someone else's
|
||||
# picker message.
|
||||
query_message = getattr(query, "message", None)
|
||||
query_chat = getattr(query_message, "chat", None)
|
||||
if not self._is_callback_user_authorized(
|
||||
str(getattr(query.from_user, "id", "")),
|
||||
chat_id=getattr(query_message, "chat_id", None),
|
||||
chat_type=str(getattr(query_chat, "type", None)) if getattr(query_chat, "type", None) is not None else None,
|
||||
thread_id=str(getattr(query_message, "message_thread_id", None)) if getattr(query_message, "message_thread_id", None) is not None else None,
|
||||
user_name=getattr(query.from_user, "first_name", None),
|
||||
):
|
||||
await query.answer(text="⛔ You are not authorized to change this setting.")
|
||||
return
|
||||
|
||||
try:
|
||||
idx = int(data[3:])
|
||||
choice = state["choices"][idx]
|
||||
except (ValueError, IndexError):
|
||||
await query.answer(text="Invalid selection.")
|
||||
return
|
||||
|
||||
callback = state.get("on_choice_selected")
|
||||
if not callback:
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
try:
|
||||
result_text = await callback(chat_id, str(choice.get("value") or ""))
|
||||
except Exception as exc:
|
||||
logger.error("Choice picker selection failed: %s", exc)
|
||||
result_text = f"Error applying selection: {exc}"
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(result_text),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=result_text, parse_mode=None, reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer()
|
||||
self._choice_picker_state.pop(chat_id, None)
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list, page: int = 0) -> tuple:
|
||||
|
|
@ -5709,6 +5832,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
return
|
||||
|
||||
# --- Generic choice picker callbacks (/reasoning, /fast) ---
|
||||
if data.startswith("cp:"):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_choice_picker_callback(query, data, chat_id)
|
||||
return
|
||||
|
||||
# --- Gmail-triage callbacks (gt:verb:arg) ---
|
||||
if data.startswith("gt:"):
|
||||
await self._handle_gmail_triage_callback(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue