Address Copilot review on #473

- Budget the Tier 2/3 header up front so the formatter's emission stays
  strictly within _SEARCH_OUTPUT_BUDGET. Previously the fit checks only
  counted body bytes, letting the final string overflow by ~120 chars
  (header + separator) and triggering _truncate_output's head+tail
  dropout — exactly the shape this code was trying to avoid.
- Restore the (5, 3, 1) ladder in Tier 2: the analytical K from perf-2
  is kept as a starting estimate, but if that K's actual emission
  doesn't fit (the estimate ignores the header and overweights shared-
  path compression) we step down through the ladder before falling
  through to Tier 3. The previous one-shot K could collapse to counts-
  only when 3/file or 1/file would have fit.
- Only normalise rc to 0 in the capped-output path when rc < 0 (our
  SIGKILL). There's a narrow race where the child can exit naturally
  between our read and our kill; preserving a non-negative rc means
  rg's rc=2 ('matches found but some files had errors') no longer
  silently turns into a clean success when the byte cap also fires.
- Clarify _MAX_SEARCH_LINE_LENGTH doc: the cap applies to the content
  portion (after path:lineno:), not the whole emitted line.
- Add explanatory comments on the two intentional `except Exception:
  pass` blocks in _search_capture (stderr drain, pipe close in the
  cleanup finally) so static analysis and future readers can see the
  silence is deliberate.
- Tighten the budget tests: now assert strict `<= _SEARCH_OUTPUT_BUDGET`
  instead of the +512-char slack that was masking the header overflow.
- New regression tests:
  - Tier 2 ladder step-down (K=5 over budget, K=3 fits, no Tier 3 fall-through)
  - capped + rc=2 surfaces stderr instead of being normalised to success
  - capped + rc<0 (our SIGKILL) flows through as a partial-result success
This commit is contained in:
Patrick Buckley
2026-05-04 00:36:56 -07:00
parent 4e9e8ca207
commit 39d2aa2b38
2 changed files with 140 additions and 55 deletions
+54 -2
View File
@@ -3010,6 +3010,28 @@ class TestSearchLineTruncation:
output = _run_exec_search(_make_session(), (stdout, 0, b"", True)) output = _run_exec_search(_make_session(), (stdout, 0, b"", True))
assert "byte cap" in output or "capped" in output assert "byte cap" in output or "capped" in output
def test_search_capped_preserves_nonzero_rc_error(self):
"""When the byte cap fires AND the child also returned a real
error rc (rg's rc=2 = 'matches with errors'), surface the error
instead of silently treating it as success. The capped→rc=0
normalisation should only apply to the SIGKILL we issued (rc<0).
"""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(
_make_session(),
(stdout, 2, b"rg: some/file: Permission denied\n", True),
)
assert "Permission denied" in output
def test_search_capped_with_signal_kill_treated_as_success(self):
"""Capped output with rc<0 (our SIGKILL) flows through as a
successful partial result — the capped annotation in the output
signals incompleteness."""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(_make_session(), (stdout, -9, b"", True))
assert "a/b.py:1:line1" in output
assert "byte cap" in output or "capped" in output
class TestSearchBackendSelection: class TestSearchBackendSelection:
"""Tests for backend detection (rg vs grep) and arg construction.""" """Tests for backend detection (rg vs grep) and arg construction."""
@@ -3147,7 +3169,10 @@ class TestSearchOutputBudget:
assert "more in a.py" in out assert "more in a.py" in out
assert "more in b.py" in out assert "more in b.py" in out
assert "more in c.py" in out assert "more in c.py" in out
assert len(out) <= _SEARCH_OUTPUT_BUDGET + 512 # small slack for header # Strict: the formatter budgets for header + separator up front,
# so the final emission stays at or below ``_SEARCH_OUTPUT_BUDGET``
# without needing ``_truncate_output`` as a backstop.
assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier3_counts_only_when_too_many_files(self): def test_tier3_counts_only_when_too_many_files(self):
"""Thousands of files × matches → degrade to per-file counts.""" """Thousands of files × matches → degrade to per-file counts."""
@@ -3162,7 +3187,7 @@ class TestSearchOutputBudget:
out = _format_search_results(records, capped=False) out = _format_search_results(records, capped=False)
assert "Counts only" in out assert "Counts only" in out
assert "path/to/file_0000.py: 50 matches" in out assert "path/to/file_0000.py: 50 matches" in out
assert len(out) <= _SEARCH_OUTPUT_BUDGET + 512 assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier1_preserves_file_order(self): def test_tier1_preserves_file_order(self):
"""Tier 1 emits files in insertion order (so first-seen file appears first).""" """Tier 1 emits files in insertion order (so first-seen file appears first)."""
@@ -3185,6 +3210,33 @@ class TestSearchOutputBudget:
out = _format_search_results(records, capped=True) out = _format_search_results(records, capped=True)
assert "byte cap" in out or "capped" in out assert "byte cap" in out or "capped" in out
def test_tier2_steps_down_ladder_before_falling_to_tier3(self):
"""When the analytical K is too aggressive, Tier 2 must step
down the (5, 3, 1) ladder before falling through to Tier 3.
Regression test for the perf-2 → ladder-collapse bug.
"""
from turnstone.core.session import _format_search_results
# Tune so K=5 doesn't fit but a smaller K does. ~70 files with
# ~30 matches each at ~120 chars/line: K=5 emits ~42 KB (over
# the 32 KB budget); K=3 emits ~25 KB (fits).
records = []
line = "x" * 100
for f_idx in range(70):
for i in range(30):
records.append((f"src/file_{f_idx:02}.py", str(i), line))
out = _format_search_results(records, capped=False)
# Did NOT collapse to Tier 3.
assert "Counts only" not in out
# Used a smaller-than-5 K — the header reports the chosen K.
# We don't assert the exact K (the analytical estimate may pick
# 1, 3, or 4), but we DO assert it's a per-file-samples result.
assert "showing first" in out
# And that it stayed within budget.
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET
assert len(out) <= _SEARCH_OUTPUT_BUDGET
class TestSearchCaptureStreaming: class TestSearchCaptureStreaming:
"""Direct tests for ``_search_capture`` — the streaming subprocess """Direct tests for ``_search_capture`` — the streaming subprocess
+86 -53
View File
@@ -196,8 +196,12 @@ def _encode_image_data_uri(raw: bytes, mime: str) -> str:
# Upper bound on total skill content injected into system messages # Upper bound on total skill content injected into system messages
_MAX_SKILL_CONTENT: int = 32768 _MAX_SKILL_CONTENT: int = 32768
# Maximum length (characters) for a single line in search results. # Maximum length (characters) for the *content portion* of an emitted
# Lines longer than this are truncated to prevent context overflow from # search result line — i.e. everything after ``path:lineno:``. The path
# and line-number prefix are intentionally not bounded by this constant
# (paths can be long but they're informational and grep/rg won't emit
# pathological values for them). Lines whose content exceeds this cap
# get re-truncated with ``_SEARCH_TRUNCATION_SUFFIX`` to defend against
# pathological files (minified blobs, base64 data, etc.). # pathological files (minified blobs, base64 data, etc.).
_MAX_SEARCH_LINE_LENGTH: int = 1024 _MAX_SEARCH_LINE_LENGTH: int = 1024
# Margin over the per-line cap before re-truncating, so backend-supplied # Margin over the per-line cap before re-truncating, so backend-supplied
@@ -381,57 +385,82 @@ def _format_search_results(
if not overflow: if not overflow:
return "\n".join(chunks) + summary return "\n".join(chunks) + summary
# Pick K analytically rather than iterating the K=5/3/1 ladder and # Tier 2 header is added on return; budget for it up front so the
# rebuilding ``chunks2`` from scratch on each retry. Sample the first # final emission stays strictly within ``_SEARCH_OUTPUT_BUDGET`` and
# ~32 records for an emitted-line-length estimate, then divide the # ``_truncate_output``'s head+tail strategy never kicks in (that
# budget by ``files * (avg + 1)`` to get a K that should fit in one # strategy silently drops middle files alphabetically — exactly the
# pass. Floor at 80 so a corpus of unusually short lines doesn't push # shape we're trying to avoid for search results).
# K artificially high (the estimate would underweight the per-line def _tier2_header(k_value: int) -> str:
# newline + the trailing "...and N more in <path>" notes). The fit h = (
# check is preserved below — the estimate is approximate and Tier 3 f"({total} matches across {files} files — "
# remains the safety net. f"showing first {k_value}/file. Narrow the query or read_file "
f"a specific path for full content.)"
)
if capped:
h += " (raw output capped; counts may underreport.)"
return h
# Sample the first ~32 records for an emitted-line-length estimate,
# then seed K from ``budget / (files * avg)`` so we usually skip
# ladder rungs that won't fit in one pass. Floor avg at 80 so a
# corpus of unusually short lines doesn't push K artificially high
# (the estimate would underweight the per-line newline + trailing
# "...and N more in <path>" notes). The ladder iteration below is
# the safety net — the estimate is approximate.
sample = records[:32] sample = records[:32]
avg = max( avg = max(
80, 80,
sum(len(p) + len(ln) + len(c) + 3 for p, ln, c in sample) // max(1, len(sample)), sum(len(p) + len(ln) + len(c) + 3 for p, ln, c in sample) // max(1, len(sample)),
) )
estimated_k = max(1, _SEARCH_OUTPUT_BUDGET // max(1, files * (avg + 1))) estimated_k = max(1, _SEARCH_OUTPUT_BUDGET // max(1, files * (avg + 1)))
k = min(_SEARCH_TIER2_SAMPLE_LADDER[0], estimated_k) # Iterate the ladder starting from the highest rung that's ≤ our
chunks2: list[str] = [] # estimate. If the chosen K's actual emission doesn't fit (the
used2 = 0 # estimate ignored the header and over-counts compression from
fit = True # shared paths), step down to the next rung instead of jumping
for path, matches in by_file.items(): # straight to Tier 3.
head = matches[:k] candidates = [k for k in _SEARCH_TIER2_SAMPLE_LADDER if k <= max(estimated_k, 1)]
for lineno, content in head: if not candidates:
line = f"{path}:{lineno}:{content}" candidates = [_SEARCH_TIER2_SAMPLE_LADDER[-1]]
chunks2.append(line) for k in candidates:
used2 += len(line) + 1 header = _tier2_header(k)
if len(matches) > k: # Budget for header + the "\n\n" separator on return.
note = f" ...and {len(matches) - k} more in {path}" body_budget = _SEARCH_OUTPUT_BUDGET - len(header) - 2
chunks2.append(note) chunks2: list[str] = []
used2 += len(note) + 1 used2 = 0
if used2 > _SEARCH_OUTPUT_BUDGET: fit = True
fit = False for path, matches in by_file.items():
break head = matches[:k]
if fit: for lineno, content in head:
header = ( line = f"{path}:{lineno}:{content}"
f"({total} matches across {files} files — " chunks2.append(line)
f"showing first {k}/file. Narrow the query or read_file " used2 += len(line) + 1
f"a specific path for full content.)" if len(matches) > k:
) note = f" ...and {len(matches) - k} more in {path}"
if capped: chunks2.append(note)
header += " (raw output capped; counts may underreport.)" used2 += len(note) + 1
return header + "\n\n" + "\n".join(chunks2) if used2 > body_budget:
fit = False
break
if fit:
return header + "\n\n" + "\n".join(chunks2)
counts = sorted(by_file.items(), key=lambda kv: (-len(kv[1]), kv[0])) counts = sorted(by_file.items(), key=lambda kv: (-len(kv[1]), kv[0]))
tier3_header = (
f"({total} matches across {files} files — too many to show inline. "
f"Counts only; narrow the query or read_file a specific path.)"
)
if capped:
tier3_header += " (raw output capped; counts may underreport.)"
# Budget for header + the "\n\n" separator + the trailing
# "(plus N more files)" line so the final emission stays within
# ``_SEARCH_OUTPUT_BUDGET`` even when the count list is enormous.
tier3_body_budget = _SEARCH_OUTPUT_BUDGET - len(tier3_header) - 2 - _SEARCH_TIER3_TAIL_RESERVE
body_lines: list[str] = [] body_lines: list[str] = []
body_used = 0 body_used = 0
shown = 0 shown = 0
for p, m in counts: for p, m in counts:
line = f"{p}: {len(m)} matches" line = f"{p}: {len(m)} matches"
# Reserve room for the "(plus N more files)" tail line so we don't if body_used + len(line) + 1 > tier3_body_budget:
# blow the budget when the count list itself is enormous.
if body_used + len(line) + 1 + _SEARCH_TIER3_TAIL_RESERVE > _SEARCH_OUTPUT_BUDGET:
break break
body_lines.append(line) body_lines.append(line)
body_used += len(line) + 1 body_used += len(line) + 1
@@ -442,13 +471,7 @@ def _format_search_results(
f"(plus {files - shown} more files with {omitted_matches} matches between them)" f"(plus {files - shown} more files with {omitted_matches} matches between them)"
) )
body = "\n".join(body_lines) body = "\n".join(body_lines)
header = ( return tier3_header + "\n\n" + body
f"({total} matches across {files} files — too many to show inline. "
f"Counts only; narrow the query or read_file a specific path.)"
)
if capped:
header += " (raw output capped; counts may underreport.)"
return header + "\n\n" + body
# Memory scopes accepted by the ``memory`` tool's preparer + executor. # Memory scopes accepted by the ``memory`` tool's preparer + executor.
@@ -8172,6 +8195,10 @@ class ChatSession:
while proc.stderr.read(_SEARCH_DRAIN_CHUNK): while proc.stderr.read(_SEARCH_DRAIN_CHUNK):
pass # discard tail so the child can finish writing pass # discard tail so the child can finish writing
except Exception: except Exception:
# Drain is best-effort: the pipe may be closed mid-read
# when ``proc.kill()`` lands or the child exits. Raising
# here would leak the daemon thread's exception to
# stderr without affecting correctness; swallow silently.
pass pass
drain_thread = threading.Thread(target=_drain_stderr, daemon=True) drain_thread = threading.Thread(target=_drain_stderr, daemon=True)
@@ -8192,11 +8219,12 @@ class ChatSession:
watchdog.cancel() watchdog.cancel()
drain_thread.join(timeout=_SEARCH_DRAIN_JOIN_TIMEOUT) drain_thread.join(timeout=_SEARCH_DRAIN_JOIN_TIMEOUT)
for stream in (proc.stdout, proc.stderr): for stream in (proc.stdout, proc.stderr):
try: # ``close()`` can raise if the pipe is already torn down
# by ``proc.kill()`` or by the OS; we're in cleanup, so
# swallow rather than mask the real return path.
with contextlib.suppress(Exception):
if stream is not None: if stream is not None:
stream.close() stream.close()
except Exception:
pass
if timed_out[0]: if timed_out[0]:
raise subprocess.TimeoutExpired(args, self.tool_timeout) raise subprocess.TimeoutExpired(args, self.tool_timeout)
@@ -8213,9 +8241,14 @@ class ChatSession:
# ripgrep and grep share rc semantics: 0 = matches, 1 = no # ripgrep and grep share rc semantics: 0 = matches, 1 = no
# matches, ≥2 = error. When ``capped`` is True we killed the # matches, ≥2 = error. When ``capped`` is True we killed the
# child intentionally (byte-cap), so its negative rc is ours # child intentionally (byte-cap), so a negative rc is from
# and we should treat the partial output as success. # our SIGKILL — normalise to 0 so the partial output flows
if capped: # through. But preserve a non-negative rc: there's a narrow
# race where the child can exit naturally between our read
# and our kill, and we don't want to silently swallow rg's
# rc=2 ("matches found but some files had errors") just
# because we also tripped the byte cap.
if capped and rc < 0:
rc = 0 rc = 0
if rc == 1: if rc == 1: