refactor(discord): simplify tool preview links
This commit is contained in:
+28
-17
@@ -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)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
+18
-25
@@ -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:
|
||||
|
||||
+10
-9
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user