Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,7 @@ def _execute(next_args: dict) -> Any:
question=next_args.get("question", ""),
choices=next_args.get("choices"),
callback=agent.clarify_callback,
context=next_args.get("context"),
)
function_result, function_args = _run_agent_tool_execution_middleware(
agent,
Expand Down
135 changes: 81 additions & 54 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
_DISCORD_MAX_APP_COMMANDS = 100
_DISCORD_SELECT_FIELD_LIMIT = 100
_DISCORD_BUTTON_LABEL_LIMIT = 80
# Discord recommends concise button labels (about 38 characters without an
# icon). Clarify buttons include an option number, so the whole visible label
# stays within that mobile-friendly budget even though the API allows 80.
_DISCORD_CLARIFY_BUTTON_SOFT_LIMIT = 38
_DISCORD_ELLIPSIS = "\u2026"
_DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({
"non_conversational",
Expand Down Expand Up @@ -133,6 +137,33 @@ def _truncate_discord_component_text(text: str, limit: int) -> str:
return _prefix_within_utf16_limit(str(text or ""), max(0, limit))


def _clarify_choice_button_label(index: int, choice: str) -> str:
"""Build ``1 · Approve`` from ``Approve — full explanation``.

The full canonical choice remains in the message body and callback. This
compact label is only a mobile-friendly pointer to the matching numbered
row, never the sole source of decision context.
"""
prefix = f"{index + 1} · "
text = " ".join(str(choice or "").split())
short = text
for separator in (" — ", " – ", " - ", ": "):
head, found, _tail = text.partition(separator)
if found and head.strip():
short = head.strip()
break

budget = _DISCORD_CLARIFY_BUTTON_SOFT_LIMIT - utf16_len(prefix)
if utf16_len(short) > budget:
short = (
_prefix_within_utf16_limit(
short, budget - utf16_len(_DISCORD_ELLIPSIS)
).rstrip()
+ _DISCORD_ELLIPSIS
)
return f"{prefix}{short}"


async def _wait_for_ready_or_bot_exit(
ready_event: asyncio.Event,
bot_task: asyncio.Task,
Expand Down Expand Up @@ -5800,9 +5831,34 @@ def _flatten_choice(c):
clean_choices = clean_choices[:24]

if clean_choices:
# Mirror the full choice text as a numbered list, same as the
# Telegram/WhatsApp adapters. Button labels are capped at 80
# chars by Discord and become unreadable on mobile long before
# that (see ClarifyChoiceView), so the button carries only the
# option number plus a short action label — the full text the
# user needs to make an informed decision lives here, in the
# embed field / message body, which has a much higher cap
# (1024 for embed fields).
option_lines_full = "\n".join(
f"**{i + 1}.** {c}" for i, c in enumerate(clean_choices)
)
embed_suffix = (
"\n\nPick a button below, or click ✏️ Other to type a "
"custom answer."
)
max_field = 1024
# Reserve space for the suffix before truncating the options
# list, otherwise a long-but-under-cap option_lines plus the
# suffix can push the combined field value past Discord's
# real 1024-char embed-field limit and the send silently
# fails downstream.
option_lines = option_lines_full
if len(option_lines) + len(embed_suffix) > max_field:
budget = max_field - len(embed_suffix) - 3
option_lines = option_lines_full[:budget] + "..."
embed.add_field(
name="Choices",
value="Pick one below, or click ✏️ Other to type a custom answer.",
value=f"{option_lines}{embed_suffix}",
inline=False,
)
view = ClarifyChoiceView(
Expand All @@ -5818,14 +5874,22 @@ def _flatten_choice(c):
inline=False,
)
view = None

# Mirror the question in plain content — embeds are invisible on
# some clients (see send_exec_approval).
clarify_tail = (
"\n\nPick one below, or click ✏️ Other to type a custom answer."
if clean_choices
else "\n\nReply in this channel with your answer."
)
option_lines_full = ""

# Mirror the question (and, for multi-choice, the full option
# text) in plain content — embeds are invisible on some clients
# (see send_exec_approval). The plain-text mirror uses the
# untruncated option list; Discord's message content cap (2000
# chars) is a separate, much larger limit than the embed field's
# 1024, so reusing the embed-truncated value here would cut off
# options unnecessarily.
if clean_choices:
clarify_tail = (
f"\n\n{option_lines_full}\n\nPick a button below, or click "
"✏️ Other to type a custom answer."
)
else:
clarify_tail = "\n\nReply in this channel with your answer."
content = self._self_contained_prompt_content(
"❓ **Hermes needs your input**", str(question or "").strip(),
tail=clarify_tail,
Expand Down Expand Up @@ -7524,7 +7588,7 @@ class ClarifyChoiceView(discord.ui.View):
"""Interactive button view for the clarify tool's multiple-choice prompts.

Renders one button per choice (max 24) plus a final ``✏️ Other`` button.
Picking a numeric choice resolves the gateway clarify entry immediately;
Picking a choice button resolves the gateway clarify entry immediately;
picking ``Other`` flips the entry into text-capture mode so the next
user message in the session becomes the response (the gateway's
text-intercept handles the resolution).
Expand All @@ -7550,51 +7614,14 @@ def __init__(
self.resolved = False

for index, choice in enumerate(self.choices):
# Discord button labels are capped at 80 chars. On mobile the
# visible width is much narrower (often <40 chars before it
# wraps to 2 lines and the second line gets cut off), so we
# cap aggressively and cut at a word boundary when possible
# to keep the trailing text readable.
#
# Cut strategy (most-preferred to least-preferred):
# 1. Last space in the trailing half of the budget
# (cleanest word boundary)
# 2. Last soft boundary in the trailing half of the
# budget (hyphen, comma, period, paren)
# 3. Hard cut at the budget limit (last resort)
prefix = f"{index + 1}. "
budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix)
if utf16_len(choice) <= budget:
label_body = choice
else:
truncated = _prefix_within_utf16_limit(
choice,
max(0, budget - utf16_len(_DISCORD_ELLIPSIS)),
).rstrip()
cut_at = -1
# 1. Last space in the trailing half of the budget.
space = truncated.rfind(" ")
if space >= len(truncated) // 2:
cut_at = space
# 2. Soft boundary — only if no word boundary found.
# Find the latest soft boundary in the trailing half
# of the budget; that maximizes preserved text length.
# Cut AT the soft boundary (inclusive) so the label
# ends on the soft char (e.g. "-" or ",") rather than
# on the alpha char that followed it.
if cut_at < 0:
latest_soft = max(
(truncated.rfind(s) for s in ("-", ",", ".", ")")),
default=-1,
)
if latest_soft >= len(truncated) // 2:
cut_at = latest_soft + 1
if cut_at > 0:
truncated = truncated[:cut_at]
label_body = truncated.rstrip() + _DISCORD_ELLIPSIS
# The message body carries the complete numbered choices. The
# button repeats the same number plus the short action label so
# the relationship is obvious without sacrificing mobile
# legibility. Clicking still returns the canonical full choice.
label_body = _clarify_choice_button_label(index, choice)
button = discord.ui.Button(
label=f"{prefix}{label_body}",
style=discord.ButtonStyle.primary,
label=label_body,
style=discord.ButtonStyle.secondary,
custom_id=f"clarify:{clarify_id}:{index}",
)
button.callback = self._make_choice_callback(index, choice)
Expand Down
Loading