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
167 lines
5.7 KiB
Python
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",
|
|
]
|