From 911d8dfbf45fe590aefefd764c3079c34ef38da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Coffee=E2=98=95=EF=B8=8F?= Date: Sun, 2 Aug 2026 22:29:19 -0400 Subject: [PATCH] refactor(discord): simplify tool preview links --- agent/display.py | 45 +++++++++++++++++----------- gateway/platforms/base.py | 43 +++++++++++--------------- gateway/run.py | 19 ++++++------ plugins/platforms/discord/adapter.py | 39 ++++-------------------- tests/agent/test_display.py | 37 +++++++++++++++++------ tests/gateway/test_discord_format.py | 41 ++++++------------------- 6 files changed, 99 insertions(+), 125 deletions(-) diff --git a/agent/display.py b/agent/display.py index 03e4bfe627..d6bea7e54e 100644 --- a/agent/display.py +++ b/agent/display.py @@ -188,6 +188,15 @@ def _truncate_preview(text: str, max_len: int | None) -> str: return text +@dataclass(frozen=True) +class ToolPreview: + """A compact tool preview plus presentation facts lost to truncation.""" + + text: str + truncated: bool = False + url: str | None = None + + _SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"} _SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"} @@ -557,30 +566,33 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - return preview -def get_tool_preview_url( +def prepare_tool_preview( tool_name: str, args: dict | None, *, - fallback: str | None = None, -) -> str | None: - """Return the uncapped HTTP(S) URL represented by a tool preview. + fallback: str, + max_len: int, +) -> ToolPreview: + """Build one canonical compact preview before platform formatting. - Rebuild the preview from the tool's primary display argument so callers - can recover information lost to an earlier display-length cap. ``fallback`` - covers callback paths that carry a preview but no argument dictionary. + The uncapped preview is rebuilt from the tool arguments when possible so + an upstream display cap cannot discard its link target. Platforms then + receive explicit truncation and URL metadata instead of inferring either + fact from the rendered text. """ - expanded = build_tool_preview(tool_name, args, max_len=0) - for value in (expanded, fallback): - candidate = _display_url(value) - if not candidate: - continue + full_text = build_tool_preview(tool_name, args, max_len=0) or fallback + text = _truncate_preview(full_text, max_len) + truncated = text != full_text + url = None + if truncated: + candidate = _display_url(full_text) try: parsed = urlsplit(candidate) except ValueError: - continue - if parsed.scheme.lower() in {"http", "https"} and parsed.netloc: - return candidate - return None + parsed = None + if parsed and parsed.scheme.lower() in {"http", "https"} and parsed.netloc: + url = candidate + return ToolPreview(text=text, truncated=truncated, url=url) # ========================================================================= @@ -1533,4 +1545,3 @@ def get_cute_tool_message( # ========================================================================= # Honcho session line (one-liner with clickable OSC 8 hyperlink) # ========================================================================= - diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 9b88e7c1c6..36741ae6d2 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -541,7 +541,7 @@ import dataclasses from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union from enum import Enum from pathlib import Path as _Path @@ -551,6 +551,9 @@ from gateway.config import Platform, PlatformConfig from gateway.session import SessionSource, build_session_key from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home +if TYPE_CHECKING: + from agent.display import ToolPreview + # --------------------------------------------------------------------------- # Streaming TTS format descriptor and handle (#60671) @@ -3095,37 +3098,27 @@ class BasePlatformAdapter(ABC): # progress bubbles compact — they persist as permanent messages). preview = event.preview if preview: - full_preview = preview + from agent.display import prepare_tool_preview + cap = preview_max_len if preview_max_len > 0 else 40 - if len(preview) > cap: - preview = preview[:cap - 3] + "..." - preview = self.format_tool_preview( - preview, - full_preview=full_preview, - tool_name=event.tool_name, - args=event.args, + prepared = prepare_tool_preview( + event.tool_name, + event.args, + fallback=preview, + max_len=cap, ) - return f"{emoji} {event.tool_name}: \"{preview}\"" + rendered = self.format_tool_preview(prepared) + return f"{emoji} {event.tool_name}: \"{rendered}\"" return f"{emoji} {event.tool_name}..." - def format_tool_preview( - self, - preview: str, - *, - full_preview: Optional[str] = None, - tool_name: Optional[str] = None, - args: Optional[Dict[str, Any]] = None, - ) -> str: + def format_tool_preview(self, preview: "ToolPreview") -> str: """Apply platform-native formatting to a compact tool preview. - The gateway calls this after imposing its display-length cap, while - still supplying the original preview and tool arguments. Most - platforms should preserve the compact text unchanged. Markdown - adapters may use the extra context to retain information that would - otherwise be lost during truncation (for example, Discord keeps a - truncated URL label linked to its full destination). + Most adapters only need the compact text. Rich-text adapters can use + the preview's explicit metadata to preserve details such as a URL that + was shortened for display. """ - return preview + return preview.text @property def has_fatal_error(self) -> bool: diff --git a/gateway/run.py b/gateway/run.py index 15e1ffad6f..f170f55dda 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3777,21 +3777,22 @@ class TurnRunner: from agent.display import ( get_tool_preview_max_len, get_tool_verb, + prepare_tool_preview, tool_verb_connector, verb_drops_preview, ) _pl = get_tool_preview_max_len() _cap = _pl if _pl > 0 else 40 - _full_preview = preview - if len(preview) > _cap: - preview = preview[:_cap - 3] + "..." + _prepared_preview = prepare_tool_preview( + tool_name, + args, + fallback=preview, + max_len=_cap, + ) if _progress_adapter is not None: - preview = _progress_adapter.format_tool_preview( - preview, - full_preview=_full_preview, - tool_name=tool_name, - args=args, - ) + preview = _progress_adapter.format_tool_preview(_prepared_preview) + else: + preview = _prepared_preview.text # Friendly labels: render a human-phrased line for built-in # tools ("🔍 Searching the web for ...") by prefixing the verb # onto the preview the callback already computed (so the diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f044d19272..a35ff8cf94 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -31,7 +31,7 @@ from urllib.parse import quote, urljoin from agent.async_utils import ( consume_detached_task_result as _consume_background_task_result, ) -from agent.display import get_tool_preview_url +from agent.display import ToolPreview logger = logging.getLogger(__name__) @@ -977,40 +977,13 @@ class DiscordAdapter(BasePlatformAdapter): PLAYBACK_TIMEOUT = 120 PLAYBACK_TIMEOUT_PADDING = 30 - def format_tool_preview( - self, - preview: str, - *, - full_preview: Optional[str] = None, - tool_name: Optional[str] = None, - args: Optional[Dict[str, Any]] = None, - ) -> str: + def format_tool_preview(self, preview: ToolPreview) -> str: """Keep a truncated URL preview clickable in Discord markdown.""" - if preview.endswith("..."): - visible_prefix = preview[:-3] - elif preview.endswith(_DISCORD_ELLIPSIS): - visible_prefix = preview[:-1] - else: - return preview + if not preview.url: + return preview.text - destination = get_tool_preview_url( - tool_name or "", - args, - fallback=full_preview, - ) - if ( - not destination - or destination == preview - or not destination.startswith(visible_prefix) - ): - return preview - - # Escape the label's link delimiters and URL-encode characters that - # can prematurely terminate Discord's markdown destination. - label = ( - preview.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") - ) - href = quote(destination, safe=":/?#[]@!$&'*+,;=%") + label = re.sub(r"([\\\[\]])", r"\\\1", preview.text) + href = quote(preview.url, safe=":/?#[]@!$&'*+,;=%") return f"[{label}]({href})" def __init__(self, config: PlatformConfig): diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 4896108d56..a736961f80 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -10,7 +10,7 @@ from agent.display import ( capture_local_edit_snapshot, extract_edit_diff, get_cute_tool_message, - get_tool_preview_url, + prepare_tool_preview, redact_tool_args_for_display, set_tool_preview_max_len, _render_inline_unified_diff, @@ -107,23 +107,42 @@ class TestBuildToolPreview: assert build_tool_preview("terminal", []) is None -class TestGetToolPreviewUrl: - def test_recovers_uncapped_web_extract_url(self): +class TestPrepareToolPreview: + def test_recovers_and_describes_truncated_url(self): url = "https://example.com/a/very/long/path/to/a/page" set_tool_preview_max_len(20) - assert get_tool_preview_url("web_extract", {"urls": [url]}) == url + preview = prepare_tool_preview( + "web_extract", + {"urls": [url]}, + fallback=url[:17] + "...", + max_len=20, + ) - def test_uses_callback_preview_when_args_are_missing(self): + assert preview.text == url[:17] + "..." + assert preview.truncated is True + assert preview.url == url + + def test_untruncated_url_has_no_link_target(self): url = "https://example.com/page" + preview = prepare_tool_preview( + "browser_navigate", None, fallback=url, max_len=40 + ) - assert get_tool_preview_url("browser_navigate", None, fallback=url) == url + assert preview.text == url + assert preview.truncated is False + assert preview.url is None - def test_rejects_non_url_preview(self): - assert get_tool_preview_url( + def test_truncated_non_url_has_no_link_target(self): + preview = prepare_tool_preview( "web_search", {"query": "how to parse a URL"}, - ) is None + fallback="how to parse a URL", + max_len=12, + ) + + assert preview.truncated is True + assert preview.url is None class TestCuteToolMessagePreviewLength: diff --git a/tests/gateway/test_discord_format.py b/tests/gateway/test_discord_format.py index fbeb2ed1b2..420d58cbda 100644 --- a/tests/gateway/test_discord_format.py +++ b/tests/gateway/test_discord_format.py @@ -46,16 +46,13 @@ class TestDiscordFormatMessage: class TestDiscordToolPreviewFormatting: def test_truncated_url_keeps_full_click_target(self): + from agent.display import ToolPreview + adapter = _make_discord_adapter() url = "https://hermes-agent.nousresearch.com/docs/gateway/discord/tool-progress" visible = "https://hermes-agent.nousresearch..." - out = adapter.format_tool_preview( - visible, - full_preview=url, - tool_name="web_extract", - args={"urls": [url]}, - ) + out = adapter.format_tool_preview(ToolPreview(visible, truncated=True, url=url)) assert out == f"[{visible}]({url})" @@ -75,42 +72,22 @@ class TestDiscordToolPreviewFormatting: assert out is not None assert f"[{visible}]({url})" in out - def test_url_already_shortened_upstream_uses_full_url_from_args(self): - adapter = _make_discord_adapter() - url = "https://example.com/a/very/long/path/to/a/page" - visible = "https://example.com/a/very..." - - out = adapter.format_tool_preview( - visible, - full_preview=visible, - tool_name="browser_navigate", - args={"url": url}, - ) - - assert out == f"[{visible}]({url})" - def test_untruncated_url_remains_plain(self): + from agent.display import ToolPreview + adapter = _make_discord_adapter() url = "https://example.com/page" - out = adapter.format_tool_preview( - url, - full_preview=url, - tool_name="browser_navigate", - args={"url": url}, - ) + out = adapter.format_tool_preview(ToolPreview(url)) assert out == url def test_truncated_non_url_remains_plain(self): + from agent.display import ToolPreview + adapter = _make_discord_adapter() visible = "a long search query that was trunc..." - out = adapter.format_tool_preview( - visible, - full_preview="a long search query that was truncated for display", - tool_name="web_search", - args={"query": "a long search query that was truncated for display"}, - ) + out = adapter.format_tool_preview(ToolPreview(visible, truncated=True)) assert out == visible