fix(display): tool previews never exceed a tiny max_len (1-3)
`build_tool_preview()`'s generic-key fallback and the cute-message helpers still truncated with a bare `text[:max_len - 3] + "..."`; for max_len 1-3 the slice goes negative and returns almost the whole string (27 chars for max_len=1). `_truncate_preview` already had the guard, so the two code paths disagreed. One truncation helper (`_tail_trunc`) with the guard, used everywhere; the head-truncating `_cute_path` gets the same clamp. Salvage of PR #48483 by @HeLLGURD (current-code fix); the earliest reports and patches were #9464 (@LarHope), #9477 (@kagura-agent) and #9497. Co-authored-by: LarHope <12761142+LarHope@users.noreply.github.com> Fixes #9439
This commit is contained in:
@@ -158,15 +158,13 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _tail_trunc(text: str, limit: int) -> str:
|
||||
"""Tail-truncate to ``limit`` chars with ``...`` (0 = unlimited; no guard for limit <= 3)."""
|
||||
return text[:limit - 3] + "..." if limit > 0 and len(text) > limit else text
|
||||
|
||||
|
||||
def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
if max_len and max_len > 0 and len(text) > max_len:
|
||||
return "." * max_len if max_len <= 3 else text[:max_len - 3] + "..."
|
||||
return text
|
||||
def _tail_trunc(text: str, limit: int | None) -> str:
|
||||
"""Tail-truncate to ``limit`` chars with ``...`` (0/None = unlimited). The result never
|
||||
exceeds ``limit``: for 1-3 the ellipsis itself is clipped (``text[:limit - 3]`` would go
|
||||
negative and hand back almost the whole string, #9439)."""
|
||||
if not limit or limit <= 0 or len(text) <= limit:
|
||||
return text
|
||||
return "." * limit if limit <= 3 else text[:limit - 3] + "..."
|
||||
|
||||
|
||||
def _clip(text: str, n: int) -> str:
|
||||
@@ -340,7 +338,7 @@ def _delegate_task_goals(tasks: Any, *, per_goal_len: int) -> list[str]:
|
||||
if not isinstance(tasks, list):
|
||||
return []
|
||||
raw_goals = (task.get("goal") for task in tasks if isinstance(task, dict))
|
||||
return [_truncate_preview(("?" if g is None else _oneline(str(g))) or "?", per_goal_len) for g in raw_goals]
|
||||
return [_tail_trunc(("?" if g is None else _oneline(str(g))) or "?", per_goal_len) for g in raw_goals]
|
||||
|
||||
|
||||
def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None:
|
||||
@@ -374,21 +372,21 @@ def _delegate_action_preview(args: dict) -> str | None:
|
||||
def _preview_browser_exec(args: dict, max_len: int) -> str | None:
|
||||
label = _browser_exec_step_label(args)
|
||||
if label is not None:
|
||||
return _truncate_preview(label, max_len)
|
||||
return _truncate_preview(_oneline(str(args.get("code", "") or "")), max_len) or None
|
||||
return _tail_trunc(label, max_len)
|
||||
return _tail_trunc(_oneline(str(args.get("code", "") or "")), max_len) or None
|
||||
|
||||
|
||||
def _preview_delegate_task(args: dict, max_len: int) -> str | None:
|
||||
action_preview = _delegate_action_preview(args)
|
||||
tasks = args.get("tasks")
|
||||
if action_preview is not None:
|
||||
return _truncate_preview(action_preview, max_len)
|
||||
return _tail_trunc(action_preview, max_len)
|
||||
if tasks and isinstance(tasks, list):
|
||||
goals = _delegate_task_goals(tasks, per_goal_len=40)
|
||||
preview = f"{len(goals)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks"
|
||||
return _truncate_preview(preview, max_len)
|
||||
return _tail_trunc(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
return None if goal is None else _truncate_preview(_oneline(str(goal)), max_len) or None
|
||||
return None if goal is None else _tail_trunc(_oneline(str(goal)), max_len) or None
|
||||
|
||||
|
||||
def _preview_process_manage(args: dict, _max_len: int) -> str | None:
|
||||
@@ -407,14 +405,14 @@ def _preview_todo_list(args: dict, _max_len: int) -> str:
|
||||
def _preview_shell(key: str):
|
||||
def _build(args: dict, max_len: int) -> str | None:
|
||||
command = args.get(key)
|
||||
return None if command is None else _truncate_preview(summarize_shell_command(str(command)), max_len) or None
|
||||
return None if command is None else _tail_trunc(summarize_shell_command(str(command)), max_len) or None
|
||||
return _build
|
||||
|
||||
|
||||
def _preview_read_file(args: dict, max_len: int) -> str | None:
|
||||
path = args.get("path") or args.get("file") or args.get("filepath")
|
||||
label = (Path(str(path).replace("\\", "/")).name or str(path)) if path is not None else None
|
||||
return None if label is None else _truncate_preview(f"{label} {_read_file_line_label(args)}".strip(), max_len) or None
|
||||
return None if label is None else _tail_trunc(f"{label} {_read_file_line_label(args)}".strip(), max_len) or None
|
||||
|
||||
|
||||
def _preview_memory(args: dict, _max_len: int) -> str:
|
||||
@@ -435,7 +433,7 @@ def _preview_skill_view(args: dict, max_len: int) -> str | None:
|
||||
name = _oneline(str(args.get("name") or ""))
|
||||
file_path = args.get("file_path")
|
||||
label = (f"{name} → {_oneline(str(file_path))}" if name else _oneline(str(file_path))) if file_path else name
|
||||
return _truncate_preview(label, max_len) or None
|
||||
return _tail_trunc(label, max_len) or None
|
||||
|
||||
|
||||
# Tool-specific preview builders: f(args, max_len) -> preview. Tools not listed
|
||||
@@ -475,7 +473,7 @@ def prepare_tool_preview(tool_name: str, args: dict | None, *, fallback: str, ma
|
||||
"""Compact preview plus explicit truncation/URL facts (the uncapped preview is
|
||||
rebuilt from the arguments so an upstream display cap cannot drop its link target)."""
|
||||
full_text = build_tool_preview(tool_name, args, max_len=0) or fallback
|
||||
text = _truncate_preview(full_text, max_len)
|
||||
text = _tail_trunc(full_text, max_len)
|
||||
truncated = text != full_text
|
||||
url = _http_url(_display_url(full_text)) if truncated else None
|
||||
return ToolPreview(text=text, truncated=truncated, url=url)
|
||||
@@ -945,7 +943,9 @@ def _cute_path(p) -> str:
|
||||
"""Head-truncate a path to the configured preview cap, keeping the filename end."""
|
||||
p = str(p)
|
||||
limit = _tool_preview_max_len
|
||||
return ("..." + p[-(limit-3):]) if limit and len(p) > limit else p
|
||||
if not limit or len(p) <= limit:
|
||||
return p
|
||||
return "." * limit if limit <= 3 else "..." + p[-(limit - 3):]
|
||||
|
||||
|
||||
def _cute_web_extract(a: dict, _r) -> str:
|
||||
|
||||
@@ -106,6 +106,21 @@ class TestBuildToolPreview:
|
||||
assert build_tool_preview("terminal", "") is None
|
||||
assert build_tool_preview("terminal", []) is None
|
||||
|
||||
@pytest.mark.parametrize("max_len", [1, 2, 3, 4])
|
||||
def test_tiny_max_len_never_exceeded(self, max_len):
|
||||
"""max_len is a hard cap on every preview path — dedicated builder (terminal), generic
|
||||
fallback key (web_search), and the cute head-truncated path (#9439)."""
|
||||
from agent.display import _cute_path, set_tool_preview_max_len
|
||||
long = "abcdefghijklmnopqrstuvwxyz"
|
||||
for tool, args in (("terminal", {"command": long}), ("web_search", {"query": long})):
|
||||
preview = build_tool_preview(tool, args, max_len=max_len)
|
||||
assert preview and len(preview) <= max_len, (tool, preview)
|
||||
set_tool_preview_max_len(max_len)
|
||||
try:
|
||||
assert len(_cute_path("/" + long + "/file.py")) <= max_len
|
||||
finally:
|
||||
set_tool_preview_max_len(0)
|
||||
|
||||
|
||||
class TestPrepareToolPreview:
|
||||
def test_recovers_and_describes_truncated_url(self):
|
||||
|
||||
Reference in New Issue
Block a user