Files
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

167 lines
5.7 KiB
Python

"""Tool-result truncation and on-demand expansion buffer for the CLI.
Tool outputs in agent loops (RAG hits, web search dumps, file reads, code
execution stdout) can run to hundreds of lines. Streaming the full body into
the terminal drowns the reasoning around it and forces the user to scroll
past noise on every turn. We instead show a short head and stash the full
body so the REPL can re-print it on request — mirroring the
``… +N lines (ctrl+o to expand)`` UX users already know from other CLI
agents.
This module is pure logic so it can be unit-tested without a live REPL:
* :func:`truncate_for_display` decides what to show now.
* :class:`ToolResultBuffer` remembers the full bodies so the
``/show`` REPL command (and any future Ctrl+O keybinding) can expand
them later.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Defaults tuned for "agent reasoning is the signal, tool dumps are the
# noise". Ten lines is enough to recognise the result; long single lines
# (a giant JSON blob, a binary blob) get a hard character cap so the head
# never blows past one screen.
DEFAULT_HEAD_LINES = 10
DEFAULT_LINE_HARD_CAP = 240
# Cap how many recent tool results we hold onto so a long REPL session
# doesn't keep every tool dump in memory forever. The /show command only
# needs the recent ones; older results stay in the session DB if anyone
# wants the full archive.
DEFAULT_RING_CAPACITY = 32
def truncate_for_display(
body: str,
*,
head_lines: int = DEFAULT_HEAD_LINES,
line_hard_cap: int = DEFAULT_LINE_HARD_CAP,
) -> tuple[str, int]:
"""Return ``(visible_text, hidden_line_count)`` for an inline preview.
``hidden_line_count == 0`` means the full body fits within the budget
and the caller should not append an expansion hint. The returned
``visible_text`` already has overlong single lines clipped with an
ellipsis so the head can't blow past one screen on JSON-style output.
"""
if head_lines <= 0:
return "", _line_count(body)
lines = body.split("\n")
if len(lines) <= head_lines:
head_slice = lines
hidden = 0
else:
head_slice = lines[:head_lines]
hidden = len(lines) - head_lines
clipped = [_clip_long_line(line, line_hard_cap) for line in head_slice]
return "\n".join(clipped), hidden
def _line_count(body: str) -> int:
if not body:
return 0
return body.count("\n") + 1
def _clip_long_line(line: str, line_hard_cap: int) -> str:
if line_hard_cap <= 0 or len(line) <= line_hard_cap:
return line
# Keep most of the line, append a marker so the reader knows the line
# itself was clipped (independent of the line-count hidden marker).
return line[: line_hard_cap - 1] + "…"
@dataclass(slots=True)
class ToolResultEntry:
"""One captured tool result that can later be expanded by ``/show``."""
index: int # 1-based, monotonically increasing per REPL session
label: str # tool name as reported by the stream
body: str # untruncated text
@dataclass(slots=True)
class ToolResultBuffer:
"""Bounded ring of recent tool results.
The buffer is intentionally tiny — its only purpose is to back the
``/show`` REPL command, not to be a transcript store.
"""
capacity: int = DEFAULT_RING_CAPACITY
head_lines: int = DEFAULT_HEAD_LINES
line_hard_cap: int = DEFAULT_LINE_HARD_CAP
_entries: list[ToolResultEntry] = field(default_factory=list)
_next_index: int = 1
def remember(self, label: str, body: str) -> ToolResultEntry:
entry = ToolResultEntry(index=self._next_index, label=label or "tool", body=body)
self._next_index += 1
self._entries.append(entry)
if len(self._entries) > self.capacity:
# Drop oldest; preserve indices so ``/show 7`` keeps meaning
# "the seventh tool result of this session" even after older
# entries fall out of the ring.
del self._entries[: len(self._entries) - self.capacity]
return entry
def truncate(self, body: str) -> tuple[str, int]:
"""Front-door to :func:`truncate_for_display` using this buffer's policy."""
return truncate_for_display(
body,
head_lines=self.head_lines,
line_hard_cap=self.line_hard_cap,
)
def last(self) -> ToolResultEntry | None:
return self._entries[-1] if self._entries else None
def get(self, selector: str | int | None) -> ToolResultEntry | None:
"""Resolve ``/show`` arguments: ``None``/``"last"`` → most recent;
a positive integer → entry with that 1-based index;
any other string → most recent entry whose label matches.
"""
if selector is None or selector == "" or selector == "last":
return self.last()
if isinstance(selector, int):
return self._by_index(selector)
text = str(selector).strip()
if text.isdigit():
return self._by_index(int(text))
for entry in reversed(self._entries):
if entry.label == text:
return entry
return None
def entries(self) -> list[ToolResultEntry]:
"""Snapshot of current contents (newest last). For inspection/tests."""
return list(self._entries)
def clear(self) -> None:
self._entries.clear()
self._next_index = 1
def _by_index(self, idx: int) -> ToolResultEntry | None:
for entry in self._entries:
if entry.index == idx:
return entry
return None
__all__ = [
"DEFAULT_HEAD_LINES",
"DEFAULT_LINE_HARD_CAP",
"DEFAULT_RING_CAPACITY",
"ToolResultBuffer",
"ToolResultEntry",
"truncate_for_display",
]