diff --git a/strix/tools/output_store.py b/strix/tools/output_store.py index 11de2528..feb170ba 100644 --- a/strix/tools/output_store.py +++ b/strix/tools/output_store.py @@ -33,6 +33,14 @@ _DEFAULT_STORE_DIR = Path.home() / ".strix" / "tool-output" # 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] = {} @@ -243,12 +251,16 @@ def read_stored_output(output_id: str, *, offset: int = 0, limit: int = _PAGE_MA size = path.stat().st_size if start >= size: return "" - # Floor at 4 bytes (the max UTF-8 char length) so a page always makes - # progress past a single multi-byte character. - budget = min(max(4, limit), _PAGE_MAX_BYTES) + # Reserve the continuation hint's bytes so a full page plus its appended + # metadata still fits the ceiling (retrieval bypasses the result wrapper). + # next_offset can never exceed the file size, so formatting with ``size`` + # is an exact upper bound on the hint length. Floor content at 4 bytes (the + # max UTF-8 char length) so a page always makes progress past one char. + hint_reserve = len(_CONTINUATION_HINT.format(output_id=output_id, offset=size)) + content_budget = min(max(4, limit), _PAGE_MAX_BYTES - hint_reserve) with path.open("rb") as handle: handle.seek(start) - chunk = handle.read(budget) + chunk = handle.read(content_budget) has_more = start + len(chunk) < size if has_more: @@ -262,8 +274,5 @@ def read_stored_output(output_id: str, *, offset: int = 0, limit: int = _PAGE_MA chunk = _trim_incomplete_utf8_head(chunk) shown = chunk.decode("utf-8", errors="replace") if has_more: - shown += ( - "\n\n[... more; call read_tool_output(" - f'output_id="{output_id}", offset={next_offset}) to continue ...]' - ) + shown += _CONTINUATION_HINT.format(output_id=output_id, offset=next_offset) return shown diff --git a/tests/test_output_store.py b/tests/test_output_store.py index 5aba71aa..0541971a 100644 --- a/tests/test_output_store.py +++ b/tests/test_output_store.py @@ -6,6 +6,7 @@ import re from typing import TYPE_CHECKING from strix.tools.output_store import ( + _PAGE_MAX_BYTES, bound_and_store, bound_text, configure_output_store, @@ -125,6 +126,21 @@ def test_read_stored_output_paginates(tmp_path: Path) -> None: assert "offset=10" in page +def test_read_stored_output_page_including_hint_stays_within_ceiling(tmp_path: Path) -> None: + # A full non-final page plus its continuation hint must not exceed the page + # ceiling — retrieval bypasses the general result-bounding wrapper. + configure_output_store(tmp_path) + text = "x" * (_PAGE_MAX_BYTES * 3) + output_id = re.search( + r'output_id="([0-9a-f]{32})"', + bound_and_store(text, max_lines=4, max_bytes=1_000), + ) + assert output_id is not None + page = read_stored_output(output_id.group(1), offset=0, limit=_PAGE_MAX_BYTES) + assert "more;" in page # a hint was appended (non-final page) + assert len(page.encode("utf-8")) <= _PAGE_MAX_BYTES + + def test_read_stored_output_pages_long_lines_losslessly(tmp_path: Path) -> None: # A single line far larger than the page budget must be split across pages # (never returned whole), and paging forward must reconstruct the output