Files
strix/strix/tools/output_store.py
T
Ahmed Allam 9564857686 fix(context): honor the caller's retrieval limit or reject it explicitly
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.
2026-07-26 00:51:34 +00:00

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
)