mirror of
https://github.com/usestrix/strix.git
synced 2026-08-22 11:02:08 +02:00
A final page carries no continuation hint, so it uses the whole budget and honors any limit exactly. A non-final page's content plus hint must fit the limit; a limit too small to hold a progressing page and its hint is now rejected with a clear message rather than silently exceeding the documented maximum.
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Bound oversized tool results before they enter agent history.
|
|
|
|
A single verbose tool result (a recursive ``find``, a noisy scanner, a full
|
|
page dump) can otherwise pin the whole conversation near the model's context
|
|
limit for the rest of the scan. Oversized results are spilled to a per-scan
|
|
store on disk; what the agent sees is a head + tail slice plus an id it can
|
|
pass to ``read_tool_output`` to page through the full content on demand — so
|
|
truncated detail is bounded in history but never lost.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TRUNCATION_NOTICE = "[... {lines} lines ({bytes} bytes) truncated ...]"
|
|
_SPILL_NOTICE = (
|
|
"[... {lines} lines ({bytes} bytes) truncated — full output saved as "
|
|
'output_id="{output_id}"; read it with read_tool_output(output_id="{output_id}") ...]'
|
|
)
|
|
|
|
_OUTPUT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
_DEFAULT_STORE_DIR = Path.home() / ".strix" / "tool-output"
|
|
|
|
# Byte ceiling for a single retrieval page so retrieval itself can never
|
|
# overflow history — even for one very long line. Retrieval pages by byte
|
|
# offset (not by line) precisely so an oversized line is split across pages
|
|
# instead of returned whole.
|
|
_PAGE_MAX_BYTES = 50 * 1024
|
|
|
|
# Appended to a non-final page so the agent can request the next one. Its bytes
|
|
# are reserved out of the page budget so a full page plus this hint still fits
|
|
# ``_PAGE_MAX_BYTES``.
|
|
_CONTINUATION_HINT = (
|
|
"\n\n[... more; call read_tool_output("
|
|
'output_id="{output_id}", offset={offset}) to continue ...]'
|
|
)
|
|
|
|
# Single-key holder so the configured path can be swapped per scan without a
|
|
# module-level ``global`` rebind.
|
|
_config: dict[str, Path] = {}
|
|
|
|
|
|
def configure_output_store(directory: Path) -> None:
|
|
"""Point the tool-output store at ``directory`` (created on demand)."""
|
|
_config["dir"] = directory
|
|
|
|
|
|
def _active_store_dir() -> Path:
|
|
directory = _config.get("dir", _DEFAULT_STORE_DIR)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
return directory
|
|
|
|
|
|
def _byte_len(text: str) -> int:
|
|
return len(text.encode("utf-8"))
|
|
|
|
|
|
def _take_prefix(text: str, max_bytes: int) -> str:
|
|
budget = 0
|
|
out: list[str] = []
|
|
for char in text:
|
|
size = len(char.encode("utf-8"))
|
|
if budget + size > max_bytes:
|
|
break
|
|
out.append(char)
|
|
budget += size
|
|
return "".join(out)
|
|
|
|
|
|
def _take_suffix(text: str, max_bytes: int) -> str:
|
|
budget = 0
|
|
out: list[str] = []
|
|
for char in reversed(text):
|
|
size = len(char.encode("utf-8"))
|
|
if budget + size > max_bytes:
|
|
break
|
|
out.append(char)
|
|
budget += size
|
|
out.reverse()
|
|
return "".join(out)
|
|
|
|
|
|
def _head_tail(
|
|
text: str, max_lines: int, max_bytes: int, *, notice_template: str = _TRUNCATION_NOTICE
|
|
) -> tuple[str, str, int, int] | None:
|
|
"""Head/tail slices plus dropped line/byte counts, or ``None`` if small.
|
|
|
|
``max_bytes`` bounds the *entire* joined result, so the notice and its two
|
|
blank-line separators are reserved out of the byte budget before slicing —
|
|
otherwise ``head + tail`` alone could already consume the whole budget and
|
|
the appended metadata would push the persisted value over ``max_bytes``.
|
|
``max_bytes`` must be large enough to hold the notice itself (guaranteed by
|
|
the ``tool_output_max_bytes`` config floor).
|
|
"""
|
|
lines = text.split("\n")
|
|
total_bytes = _byte_len(text)
|
|
if len(lines) <= max_lines and total_bytes <= max_bytes:
|
|
return None
|
|
|
|
# Upper-bound the notice size using the largest possible counts (and a
|
|
# full-length id); the real notice is never longer. ``+ 4`` covers the two
|
|
# ``\n\n`` separators added by ``_join``.
|
|
notice_overhead = (
|
|
_byte_len(notice_template.format(lines=len(lines), bytes=total_bytes, output_id="0" * 32))
|
|
+ 4
|
|
)
|
|
byte_budget = max(2, max_bytes - notice_overhead)
|
|
|
|
head_lines = max(1, max_lines // 2)
|
|
tail_lines = max_lines - head_lines
|
|
head = "\n".join(lines[:head_lines])
|
|
tail = "\n".join(lines[len(lines) - tail_lines :]) if tail_lines > 0 else ""
|
|
|
|
# Enforce the byte budget even when the line count alone was fine.
|
|
half_bytes = max(1, byte_budget // 2)
|
|
if _byte_len(head) > half_bytes:
|
|
head = _take_prefix(head, half_bytes)
|
|
if tail and _byte_len(tail) > half_bytes:
|
|
tail = _take_suffix(tail, half_bytes)
|
|
|
|
# Count kept lines from the final slices: the byte pass above may have
|
|
# dropped whole lines from head/tail, so deriving this from the original
|
|
# head_lines/tail_lines would undercount what was actually removed.
|
|
kept_lines = len(head.split("\n")) + (len(tail.split("\n")) if tail else 0)
|
|
dropped_lines = max(0, len(lines) - kept_lines)
|
|
dropped_bytes = max(0, total_bytes - _byte_len(head) - _byte_len(tail))
|
|
return head, tail, dropped_lines, dropped_bytes
|
|
|
|
|
|
def _join(head: str, tail: str, notice: str) -> str:
|
|
return f"{head}\n\n{notice}\n\n{tail}" if tail else f"{head}\n\n{notice}"
|
|
|
|
|
|
def bound_text(text: str, *, max_lines: int, max_bytes: int) -> str:
|
|
"""Return ``text`` unchanged when small, else a head+tail preview.
|
|
|
|
Truncation happens on whichever limit is hit first (line count or UTF-8
|
|
byte size). The removed middle is replaced with a notice recording how
|
|
many lines and bytes were dropped so the agent knows output was elided.
|
|
Nothing is persisted; use :func:`bound_and_store` to keep the full output.
|
|
"""
|
|
parts = _head_tail(text, max_lines, max_bytes)
|
|
if parts is None:
|
|
return text
|
|
head, tail, dropped_lines, dropped_bytes = parts
|
|
return _join(head, tail, _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes))
|
|
|
|
|
|
def store_full_output(text: str) -> str | None:
|
|
"""Persist ``text`` and return its output id, or ``None`` if writing fails."""
|
|
output_id = uuid.uuid4().hex
|
|
try:
|
|
(_active_store_dir() / f"{output_id}.txt").write_text(text, encoding="utf-8")
|
|
except OSError:
|
|
logger.exception("failed to persist oversized tool output")
|
|
return None
|
|
return output_id
|
|
|
|
|
|
def bound_and_store(text: str, *, max_lines: int, max_bytes: int) -> str:
|
|
"""Like :func:`bound_text`, but spill the full output and reference its id.
|
|
|
|
When truncation happens the complete output is written to the store and the
|
|
preview's notice tells the agent the ``output_id`` to pass to
|
|
``read_tool_output``. Falls back to a plain preview if the spill fails.
|
|
"""
|
|
# Reserve for the (longer) spill notice so the preview honours max_bytes
|
|
# whether or not the spill succeeds.
|
|
parts = _head_tail(text, max_lines, max_bytes, notice_template=_SPILL_NOTICE)
|
|
if parts is None:
|
|
return text
|
|
head, tail, dropped_lines, dropped_bytes = parts
|
|
output_id = store_full_output(text)
|
|
notice = (
|
|
_SPILL_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes, output_id=output_id)
|
|
if output_id is not None
|
|
else _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes)
|
|
)
|
|
return _join(head, tail, notice)
|
|
|
|
|
|
def _boundary_offset(path: Path, offset: int) -> int:
|
|
"""Advance ``offset`` past a partial leading character to the next boundary.
|
|
|
|
A caller-supplied offset can land inside a multi-byte character; its orphaned
|
|
continuation bytes (0b10xxxxxx) belong to a character whose lead byte is
|
|
earlier, so we skip forward to the next character start. This keeps the page
|
|
on a real boundary (no replacement characters) and guarantees each page makes
|
|
forward progress. A UTF-8 char is at most 4 bytes, so at most 3 continuation
|
|
bytes are ever skipped. Forward-paged offsets already sit on a boundary, so
|
|
this only affects an explicit caller-chosen offset.
|
|
"""
|
|
with path.open("rb") as handle:
|
|
handle.seek(offset)
|
|
lead = handle.read(3)
|
|
skip = 0
|
|
while skip < len(lead) and lead[skip] & 0xC0 == 0x80:
|
|
skip += 1
|
|
return offset + skip
|
|
|
|
|
|
def _trim_incomplete_utf8_tail(chunk: bytes) -> bytes:
|
|
"""Drop a trailing partial UTF-8 sequence so ``chunk`` decodes cleanly.
|
|
|
|
A fixed byte window can land in the middle of a multi-byte character; the
|
|
incomplete tail bytes are dropped here and re-read on the next page (the
|
|
caller advances the offset by the *kept* length), so nothing is lost.
|
|
"""
|
|
# A UTF-8 char is 1-4 bytes; scan back over continuation bytes (0b10xxxxxx)
|
|
# to the last lead byte, then keep it only if the whole char is present.
|
|
index = len(chunk) - 1
|
|
steps = 0
|
|
while index >= 0 and chunk[index] & 0xC0 == 0x80 and steps < 3:
|
|
index -= 1
|
|
steps += 1
|
|
if index < 0:
|
|
return chunk
|
|
lead = chunk[index]
|
|
if lead & 0x80 == 0x00:
|
|
expected = 1
|
|
elif lead & 0xE0 == 0xC0:
|
|
expected = 2
|
|
elif lead & 0xF0 == 0xE0:
|
|
expected = 3
|
|
elif lead & 0xF8 == 0xF0:
|
|
expected = 4
|
|
else:
|
|
return chunk # invalid lead byte; leave for errors="replace" to handle
|
|
if len(chunk) - index < expected:
|
|
return chunk[:index]
|
|
return chunk
|
|
|
|
|
|
def read_stored_output(output_id: str, *, offset: int = 0, limit: int = _PAGE_MAX_BYTES) -> str:
|
|
"""Return a bounded byte-window of a stored output starting at byte ``offset``.
|
|
|
|
``output_id`` must be a token previously returned in a truncation notice; it
|
|
is validated to prevent path traversal. The *complete* response — content
|
|
plus any continuation hint — is bounded by a UTF-8 byte budget (``limit``,
|
|
capped at ``_PAGE_MAX_BYTES``) so it can never overflow history; even a
|
|
single very long line is split across pages rather than returned whole.
|
|
Paging forward with the printed ``offset`` hint reconstructs the full output
|
|
byte-for-byte.
|
|
"""
|
|
if not _OUTPUT_ID_RE.match(output_id):
|
|
return f"Invalid output_id: {output_id!r}"
|
|
path = _active_store_dir() / f"{output_id}.txt"
|
|
if not path.is_file():
|
|
return f"No stored output for output_id={output_id!r} (it may have expired)."
|
|
|
|
size = path.stat().st_size
|
|
start = _boundary_offset(path, max(0, offset)) if offset > 0 else 0
|
|
if start >= size:
|
|
return ""
|
|
|
|
effective = min(max(4, limit), _PAGE_MAX_BYTES)
|
|
# A final page carries no continuation hint, so the whole remaining output
|
|
# can use the budget and any limit is honoured exactly.
|
|
if size - start <= effective:
|
|
with path.open("rb") as handle:
|
|
handle.seek(start)
|
|
return handle.read().decode("utf-8", errors="replace")
|
|
|
|
# A non-final page's complete response is content + a continuation hint, and
|
|
# the whole thing must fit ``effective``. next_offset can never exceed the
|
|
# file size, so formatting with ``size`` is an exact upper bound on the hint.
|
|
# A page needs at least one char (4 bytes) of content to make progress, so a
|
|
# limit too small to hold that plus the hint is rejected rather than silently
|
|
# exceeded.
|
|
hint_reserve = len(_CONTINUATION_HINT.format(output_id=output_id, offset=size))
|
|
content_budget = effective - hint_reserve
|
|
if content_budget < 4:
|
|
return (
|
|
f"limit={limit} is too small to page this output; "
|
|
f"request at least {hint_reserve + 4} bytes."
|
|
)
|
|
with path.open("rb") as handle:
|
|
handle.seek(start)
|
|
chunk = _trim_incomplete_utf8_tail(handle.read(content_budget))
|
|
next_offset = start + len(chunk)
|
|
return chunk.decode("utf-8", errors="replace") + _CONTINUATION_HINT.format(
|
|
output_id=output_id, offset=next_offset
|
|
)
|