{"repo":"NousResearch/hermes-agent","public_id":"37134","display_label":"Discord clarify choices rendering and truncation fixes","summary":"These issues and PRs address how clarify choices are displayed in Discord, covering problems with dict choices being shown as raw Python repr, mid-word truncation of button labels, and proposals to switch from truncated buttons to a Select menu.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":48846,"merged_at":"2026-06-19T13:31:09+00:00"},"resolution":{"state":"resolved","by":"pr-48846"},"freshness":{"state":"verified","last_verified_at":"2026-06-19T13:31:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":37134,"pr_number":48846,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-19T13:31:09+00:00"}],"members":[{"kind":"issue","number":37134,"title":"fix(discord): clarify button shows Python repr of dict choices + mid-word truncation","url":null,"state":"closed"},{"kind":"pr","number":37136,"title":"fix(discord): unwrap dict choices + smart-truncate clarify button labels","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bce1e36b57","number":0},{"kind":"pr","value":"#48846","number":48846}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48846,"title":"fix(clarify): unwrap dict choices at the source + smart-truncate Discord buttons","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/discord/adapter.py","scripts/release.py","tests/gateway/test_discord_clarify_buttons.py","tests/tools/test_clarify_tool.py","tools/clarify_tool.py"],"fix_hunks":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -4566,6 +4566,13 @@ async def send_clarify(\n         Open-ended mode (``choices`` empty/None): renders the question as\n         plain embed text — no buttons. The gateway's text-intercept captures\n         the next message in this session and resolves the clarify.\n+\n+        Choice normalisation: ``choices`` may contain bare strings OR dicts\n+        (LLMs sometimes emit ``[{\"description\": \"...\"}]`` instead of bare\n+        strings, which would otherwise render as raw Python repr on the\n+        button label). Dict choices are unwrapped against the canonical\n+        LLM tool-call keys ``label``, ``description``, ``text``, ``title``\n+        in that order. Dicts with none of those keys are dropped.\n         \"\"\"\n         if not self._client or not DISCORD_AVAILABLE:\n             return SendResult(success=False, error=\"Not connected\")","@@ -4591,8 +4598,37 @@ async def send_clarify(\n                 color=discord.Color.orange(),\n             )\n \n+            # Normalise choices: LLMs sometimes emit `[{\"description\": \"...\"}]`\n+            # instead of bare strings, which would render as raw Python repr on\n+            # the button label. Unwrap the common shapes, then stringify.\n+            def _flatten_choice(c):\n+                if c is None:\n+                    return \"\"\n+                if isinstance(c, str):\n+                    return c.strip()\n+                if isinstance(c, dict):\n+                    # Prefer the canonical LLM tool-call user-facing keys\n+                    # in the order the LLM is most likely to emit them.\n+                    # 'name' and 'value' are deliberately NOT here: they're\n+                    # Discord-component-shaped fields that could appear in\n+                    # dicts that aren't meant to be choices (e.g., a\n+                    # developer-error wiring that passes a Button-shaped\n+                    # object). Picking them would leak raw enum values\n+                    # or 4-char model identifiers onto user-facing buttons.\n+                    # If a dict has none of the canonical keys, drop it\n+                    # rather than picking some random field — a garbage\n+                    # button label is worse than no button at all.\n+                    for key in (\"label\", \"description\", \"text\", \"title\"):\n+                        v = c.get(key)\n+                        if isinstance(v, str) and v.strip():\n+                            return v.strip()\n+                    return \"\"\n+                if isinstance(c, (list, tuple)):\n+                    return \" \".join(_flatten_choice(x) for x in c).strip()\n+                return str(c).strip()\n+\n             clean_choices = [\n-                str(c).strip() for c in (choices or []) if c is not None and str(c).strip()\n+                s for s in (_flatten_choice(c) for c in (choices or [])) if s\n             ]\n             # Discord allows up to 5 buttons per row, 5 rows per view = 25.\n             # We reserve one slot for the \"Other\" button, so cap at 24 choices.","@@ -6129,10 +6165,47 @@ def __init__(\n             self.resolved = False\n \n             for index, choice in enumerate(self.choices):\n-                # Discord button labels are capped at 80 chars.\n-                label_body = choice if len(choice) <= 75 else choice[:72] + \"...\"\n+                # Discord button labels are capped at 80 chars. On mobile the\n+                # visible width is much narrower (often <40 chars before it\n+                # wraps to 2 lines and the second line gets cut off), so we\n+                # cap aggressively and cut at a word boundary when possible\n+                # to keep the trailing text readable.\n+                #\n+                # Cut strategy (most-preferred to least-preferred):\n+                #   1. Last space in the trailing half of the budget\n+                #      (cleanest word boundary)\n+                #   2. Last soft boundary in the trailing half of the\n+                #      budget (hyphen, comma, period, paren)\n+                #   3. Hard cut at the budget limit (last resort)\n+                prefix = f\"{index + 1}. \"\n+                budget = 80 - len(prefix)\n+                if len(choice) <= budget:\n+                    label_body = choice\n+                else:\n+                    truncated = choice[: budget - 1].rstrip()\n+                    cut_at = -1\n+                    # 1. Last space in the trailing half of the budget.\n+                    space = truncated.rfind(\" \")\n+                    if space >= budget // 2:\n+                        cut_at = space\n+                    # 2. Soft boundary — only if no word boundary found.\n+                    # Find the latest soft boundary in the trailing half\n+                    # of the budget; that maximizes preserved text length.\n+                    # Cut AT the soft boundary (inclusive) so the label\n+                    # ends on the soft char (e.g. \"-\" or \",\") rather than\n+                    # on the alpha char that followed it.\n+                    if cut_at < 0:\n+                        latest_soft = max(\n+                            (truncated.rfind(s) for s in (\"-\", \",\", \".\", \")\")),\n+                            default=-1,\n+                        )\n+                        if latest_soft >= budget // 2:\n+                            cut_at = latest_soft + 1\n+                    if cut_at > 0:\n+                        truncated = truncated[:cut_at]\n+                    label_body = truncated.rstrip() + \"…\"\n                 button = discord.ui.Button(\n-                    label=f\"{index + 1}. {label_body}\",\n+                    label=f\"{prefix}{label_body}\",\n                     style=discord.ButtonStyle.primary,\n                     custom_id=f\"clarify:{clarify_id}:{index}\",\n                 )"]},{"file":"scripts/release.py","hunks":["@@ -103,6 +103,7 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"johnjacobkenny@users.noreply.github.com\": \"johnjacobkenny\",\n     \"[redacted:email]\": \"channkim\",\n     \"[redacted:email]\": \"x1erra\",\n     \"[redacted:email]\": \"zimigit2020\","]},{"file":"tests/gateway/test_discord_clarify_buttons.py","hunks":["@@ -122,13 +122,56 @@ def test_truncates_long_choice_label(self):\n             clarify_id=\"cidZ\",\n             allowed_user_ids=set(),\n         )\n-        # 75 chars + 3 ellipsis chars in the body, plus \"1. \" prefix\n+        # 78 chars + single-char ellipsis in the body, plus \"1. \" prefix.\n+        # Uses U+2026 (…) instead of \"...\" to fit the 80-char Discord cap.\n         first_label = view.children[0].label\n         assert first_label.startswith(\"1. \")\n-        assert first_label.endswith(\"...\")\n+        assert first_label.endswith(\"\\u2026\")\n         # Final label total <= 80 (Discord cap on button labels)\n         assert len(first_label) <= 80\n \n+    def test_truncates_long_choice_label_breaks_on_word_boundary(self):\n+        # Long choice with spaces — should cut at the last whole word so the\n+        # trailing text stays readable on Discord mobile.\n+        long_choice = (\n+            \"Tight, well-illustrated, covers all 3 audiences \"\n+            \"(patients, families, curious general readers)\"\n+        )\n+        view = ClarifyChoiceView(\n+            choices=[long_choice],\n+            clarify_id=\"cidW\",\n+            allowed_user_ids=set(),\n+        )\n+        first_label = view.children[0].label\n+        assert first_label.startswith(\"1. \")\n+        assert first_label.endswith(\"\\u2026\")\n+        # No mid-word fragment before the ellipsis.\n+        assert not first_label.rstrip(\"\\u2026\").endswith(\"(\")\n+\n+    def test_truncates_long_no_space_choice_on_soft_boundary(self):\n+        # A long choice with soft boundaries (commas, hyphens) but no spaces\n+        # should still cut on a soft boundary, not mid-word. We use an input\n+        # where position 76 is NOT a soft boundary — the test only passes\n+        # if the renderer actively searches backward for a soft char\n+        # rather than blindly cutting at the budget limit.\n+        long_choice = \"a\" * 30 + \"-\" + \"b\" * 30 + \"-\" + \"c\" * 30 + \"-\" + \"d\" * 30\n+        # 30a-30b-30c-30d = 30 + 1 + 30 + 1 + 30 + 1 + 30 = 123 chars\n+        # Position 76 is 'b' (a mid-word alpha). The renderer must look back\n+        # for a '-' to cut on.\n+        view = ClarifyChoiceView(\n+            choices=[long_choice],\n+            clarify_id=\"cidSB\",\n+            allowed_user_ids=set(),\n+        )\n+        first_label = view.children[0].label\n+        assert first_label.endswith(\"\\u2026\")\n+        assert len(first_label) <= 80\n+        body = first_label[len(\"1. \"):].rstrip(\"\\u2026\")\n+        last_char = body[-1]\n+        assert last_char in {\"-\", \",\", \".\", \")\", \" \"}, (\n+            f\"Label cuts mid-word at {last_char!r}: {first_label!r}\"\n+        )\n+\n \n # ===========================================================================\n # Choice callback → resolve_gateway_clarify","@@ -404,3 +447,134 @@ async def test_filters_empty_and_whitespace_choices(self):\n         # Only 1 real choice + 1 Other = 2 children\n         assert len(view.children) == 2\n         assert \"real-choice\" in view.children[0].label\n+\n+    @pytest.mark.asyncio\n+    async def test_unwraps_dict_choices_to_description(self):\n+        # LLMs sometimes emit [{\"description\": \"...\"}] instead of bare strings\n+        # — the renderer must unwrap common dict shapes, not str() the whole\n+        # dict into a Python repr on the button label.\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 555\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        malformed = [\n+            {\"description\": \"Tight, well-illustrated\"},\n+            {\"label\": \"Use label key\"},\n+            {\"text\": \"Use text key\"},\n+            \"normal-string\",  # strings still pass through\n+        ]\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=malformed,\n+            clarify_id=\"cidU\",\n+            session_key=\"sk-U\",\n+        )\n+        kwargs = channel.send.call_args.kwargs\n+        view = kwargs[\"view\"]\n+        labels = [b.label for b in view.children[:-1]]  # exclude Other\n+        # No raw Python repr should leak onto any label.\n+        for label in labels:\n+            assert \"{'\" not in label\n+            assert \"':\" not in label\n+        # Each dict unwrapped to its inner string.\n+        assert any(\"Tight, well-illustrated\" in lbl for lbl in labels)\n+        assert any(\"Use label key\" in lbl for lbl in labels)\n+        assert any(\"Use text key\" in lbl for lbl in labels)\n+        assert any(\"normal-string\" in lbl for lbl in labels)\n+\n+    @pytest.mark.asyncio\n+    async def test_unwrap_prefers_description_over_name_in_multi_key_dict(self):\n+        # When the LLM emits both 'name' (often a short identifier in\n+        # OpenAI-style tool calls) and 'description' (the user-facing text),\n+        # the renderer must surface 'description'. The user should never see\n+        # a 4-char model identifier on a button label.\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 666\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[{\"name\": \"tight\", \"description\": \"Tight, well-illustrated\"}],\n+            clarify_id=\"cidN\",\n+            session_key=\"sk-N\",\n+        )\n+        kwargs = channel.send.call_args.kwargs\n+        view = kwargs[\"view\"]\n+        choice_label = view.children[0].label\n+        assert \"Tight, well-illustrated\" in choice_label\n+        # The 'name' value (a short identifier) must NOT have leaked.\n+        body = choice_label.split(\"1. \", 1)[1].rstrip(\"\\u2026\")\n+        assert \"tight\" not in body, f\"'name' leaked onto button: {choice_label!r}\"\n+\n+    @pytest.mark.asyncio\n+    async def test_unwrap_prefers_label_over_description(self):\n+        # When both 'label' and 'description' are present, 'label' wins.\n+        # 'label' is the canonical short user-facing text in most LLM tool\n+        # conventions; 'description' is the longer explanation.\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 777\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[{\"label\": \"Short\", \"description\": \"Long verbose explanation\"}],\n+            clarify_id=\"cidL\",\n+            session_key=\"sk-L\",\n+        )\n+        kwargs = channel.send.call_args.kwargs\n+        view = kwargs[\"view\"]\n+        choice_label = view.children[0].label\n+        assert \"Short\" in choice_label\n+        # The longer description must NOT have leaked.\n+        assert \"Long verbose\" not in choice_label, (\n+            f\"'description' leaked over 'label': {choice_label!r}\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_unwrap_does_not_pick_value_or_name_alone(self):\n+        # 'name' and 'value' are Discord-component-shaped fields that could\n+        # accidentally appear in dicts not intended as choices (e.g., a\n+        # developer-error in the gateway wiring). The renderer should not\n+        # surface them as button labels — only the well-known LLM tool-call\n+        # keys (label, description, text, title) should win.\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 888\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[\n+                {\"name\": \"only_name_here\"},   # should be filtered out\n+                {\"value\": \"only_value_here\"},  # should be filtered out\n+                {\"description\": \"real choice\"},\n+            ],\n+            clarify_id=\"cidNV\",\n+            session_key=\"sk-NV\",\n+        )\n+        kwargs = channel.send.call_args.kwargs\n+        view = kwargs[\"view\"]\n+        choice_labels = [b.label for b in view.children[:-1]]  # exclude Other\n+        # Only the well-formed dict survives.\n+        assert len(choice_labels) == 1, (\n+            f\"Expected 1 choice, got {len(choice_labels)}: {choice_labels!r}\"\n+        )\n+        assert \"real choice\" in choice_labels[0]\n+        for label in choice_labels:\n+            assert \"only_name_here\" not in label, f\"name leaked: {label!r}\"\n+            assert \"only_value_here\" not in label, f\"value leaked: {label!r}\""]},{"file":"tests/tools/test_clarify_tool.py","hunks":["@@ -9,6 +9,7 @@\n     check_clarify_requirements,\n     MAX_CHOICES,\n     CLARIFY_SCHEMA,\n+    _flatten_choice,\n )\n \n ","@@ -164,6 +165,70 @@ def test_always_returns_true(self):\n         assert check_clarify_requirements() is True\n \n \n+class TestClarifyDictChoices:\n+    \"\"\"Dict-shaped choices must be unwrapped to user-facing text at the source.\n+\n+    LLMs sometimes emit [{\"description\": \"...\"}] instead of bare strings. The\n+    naive str(c) coercion leaked the Python dict repr onto every surface (CLI\n+    panel, Discord buttons, Telegram list) AND returned it verbatim as the\n+    user's answer. _flatten_choice normalises at the one platform-agnostic\n+    entry point so the whole class is fixed in one place.\n+    \"\"\"\n+\n+    def test_flatten_unwraps_label_first(self):\n+        assert _flatten_choice({\"label\": \"Short\", \"description\": \"Long\"}) == \"Short\"\n+\n+    def test_flatten_unwraps_description_when_no_label(self):\n+        assert _flatten_choice({\"description\": \"A loose layout\"}) == \"A loose layout\"\n+\n+    def test_flatten_unwrap_order_label_over_description(self):\n+        assert _flatten_choice({\"description\": \"verbose\", \"label\": \"tight\"}) == \"tight\"\n+\n+    def test_flatten_drops_name_value_only_dict(self):\n+        # name/value are component-shaped fields, not user-facing labels —\n+        # picking them would leak raw enum values / short model ids.\n+        assert _flatten_choice({\"name\": \"tight\", \"value\": \"x\"}) == \"\"\n+\n+    def test_flatten_prefers_canonical_key_over_name(self):\n+        assert _flatten_choice({\"name\": \"tight\", \"description\": \"Tight desc\"}) == \"Tight desc\"\n+\n+    def test_flatten_drops_keyless_dict(self):\n+        assert _flatten_choice({\"foo\": \"bar\", \"n\": 1}) == \"\"\n+\n+    def test_flatten_passthrough_string_and_scalar(self):\n+        assert _flatten_choice(\"plain\") == \"plain\"\n+        assert _flatten_choice(7) == \"7\"\n+        assert _flatten_choice(None) == \"\"\n+\n+    def test_dict_choices_reach_callback_as_clean_text(self):\n+        \"\"\"The whole point: the UI callback never sees a dict repr.\"\"\"\n+        seen = []\n+\n+        def cb(question, choices):\n+            seen.extend(choices or [])\n+            return choices[0]\n+\n+        result = json.loads(clarify_tool(\n+            \"Pick a layout\",\n+            choices=[\n+                {\"choice\": \"Tight\", \"description\": \"Tight, covers all 3 points\"},\n+                {\"description\": \"Loose layout\"},\n+                {\"name\": \"modelid\", \"value\": \"abc\"},  # dropped, not leaked\n+                \"A plain string choice\",\n+            ],\n+            callback=cb,\n+        ))  # type: ignore\n+        assert seen == [\n+            \"Tight, covers all 3 points\",\n+            \"Loose layout\",\n+            \"A plain string choice\",\n+        ]\n+        # and the resolved answer is clean text, not a dict repr\n+        assert result[\"user_response\"] == \"Tight, covers all 3 points\"\n+        assert \"{\" not in result[\"user_response\"]\n+        assert all(\"{\" not in c for c in result[\"choices_offered\"])\n+\n+\n class TestClarifySchema:\n     \"\"\"Tests for the OpenAI function-calling schema.\"\"\"\n "]},{"file":"tools/clarify_tool.py","hunks":["@@ -20,6 +20,39 @@\n MAX_CHOICES = 4\n \n \n+def _flatten_choice(c) -> str:\n+    \"\"\"Coerce a single choice into its user-facing display string.\n+\n+    The schema declares choices as bare strings, but LLMs sometimes emit\n+    dict-shaped choices like ``[{\"description\": \"...\"}]``. A naive ``str(c)``\n+    turns the whole dict into its Python repr — ``{'description': '...'}`` —\n+    which then leaks onto every surface that renders the choice (CLI panel,\n+    Discord buttons, Telegram numbered list) AND is returned verbatim as the\n+    user's answer. Normalising here, at the one platform-agnostic entry point,\n+    fixes the whole class in one place instead of per-adapter.\n+\n+    Dict unwrap order is the canonical LLM tool-call user-facing keys:\n+    ``label`` → ``description`` → ``text`` → ``title``. ``name`` and ``value``\n+    are deliberately excluded — they're component-shaped fields that could\n+    carry raw enum values or short identifiers, not human-readable labels. A\n+    dict with none of the canonical keys is dropped (returns \"\"), since a\n+    garbage label is worse than no choice at all.\n+    \"\"\"\n+    if c is None:\n+        return \"\"\n+    if isinstance(c, str):\n+        return c.strip()\n+    if isinstance(c, dict):\n+        for key in (\"label\", \"description\", \"text\", \"title\"):\n+            v = c.get(key)\n+            if isinstance(v, str) and v.strip():\n+                return v.strip()\n+        return \"\"\n+    if isinstance(c, (list, tuple)):\n+        return \" \".join(_flatten_choice(x) for x in c).strip()\n+    return str(c).strip()\n+\n+\n def clarify_tool(\n     question: str,\n     choices: Optional[List[str]] = None,","@@ -48,7 +81,12 @@ def clarify_tool(\n     if choices is not None:\n         if not isinstance(choices, list):\n             return tool_error(\"choices must be a list of strings.\")\n-        choices = [str(c).strip() for c in choices if str(c).strip()]\n+        # LLMs sometimes emit dict-shaped choices (e.g. [{\"description\": \"...\"}])\n+        # instead of bare strings. _flatten_choice unwraps them to their\n+        # user-facing text here — the single platform-agnostic entry point —\n+        # so the CLI panel, Discord buttons, and Telegram list all render clean\n+        # text and the resolved answer is never a raw Python dict repr.\n+        choices = [s for s in (_flatten_choice(c) for c in choices) if s]\n         if len(choices) > MAX_CHOICES:\n             choices = choices[:MAX_CHOICES]\n         if not choices:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}