chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user