feat(mcp): disclose when search_text was bound by limit, and make the ceiling configurable - #751
Conversation
… ceiling configurable A search_text result bound by `limit` was byte-indistinguishable from a complete one. `count` is set to len(enriched), so the array and the count corroborated each other at the ceiling: 1000 matches out of 8963, reported as count 1000 with nothing saying otherwise. The byte budget has always disclosed its own truncation through _truncated_by_budget; the limit path had no equivalent. This is the failure mode a grep replacement cannot have. An agent cannot verify a search result against anything but a second tool, so a partial answer that looks whole does not degrade gracefully — it produces confident wrong conclusions. A truncated response now carries: - _truncated_by_limit: true - _limit_applied: the effective limit - count_is_exact: false, because count is a floor rather than a total - _limit_requested, when the ceiling rather than the caller chose the limit - truncation_note, naming how to widen AND ruling out the widening that does not work: a `path` filter runs over what survived truncation, not over the corpus, so slicing a subtree returns whatever was left of the global cut. The detector reads the match count BEFORE the path and scope filters, and that ordering is the substance of the fix rather than an implementation detail. The searcher stops at `limit` and the filters then run over what survived, so a response holding 952 matches can be a truncated 1000. Measuring after the filters would miss exactly the case a caller cannot detect from the outside — the reported counts of 952 and 998 in zzet#672 are that shape. Landing on the effective limit is the signal, so this can fire on a corpus holding exactly `limit` matches. A spurious "verify this" is the safe direction to be wrong in, against silently losing most of the result set. The 1000 ceiling itself is now overridable through GORTEX_SEARCH_TEXT_MAX_LIMIT (zzet#672), defaulting to 1000 so no existing caller's response changes shape. An unset, unparseable or non-positive value keeps the default rather than lifting the bound: a typo must not become an unbounded scan. Raising the ceiling without the disclosure would only move the silent cliff, which is why both land together. Tests: five end-to-end cases through the handler, including one that pins the before-the-filter ordering with a fixture whose count necessarily lands below the limit, plus tables over the two pure helpers. Ten mutants each fail their test and pass restored: the flag never published, count_is_exact always true, landing exactly on the limit not counted as truncation, truncation measured after the filters, the clamped request not disclosed, the disclosure riding on every response, the ceiling override ignored, an unusable ceiling value honored, the note dropping the recovery that does not work, and the applied limit not reported. go build ./... clean. The compact tools/list surface is byte-identical at 14984 (search_text is a closed tool, not part of the facade preset). Closes zzet#674 Refs zzet#672 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…budget CI was red on TestToolsListByteCeilings: the core preset must stay under its 97500-byte pre-diet baseline, and the limit description added 233 bytes, putting it at 97671. main sits at 97438, so the whole available headroom was 62 bytes. The description now says only what an agent cannot get anywhere else before calling: that a bound result sets _truncated_by_limit. 41 bytes, landing at 97479. The recovery detail it used to carry — how to widen, and that a `path` filter cannot recover the remainder — already rides on the response in truncation_note, which is where a caller reads it after seeing the flag. I checked the wrong ceiling before pushing. TestCompactToolsListIsStaticAndBudgeted covers the facade surface, where search_text is not present and the cost really was zero; the budget this change spends is the core preset's, measured by TestToolsListByteCeilings. Both now pass. Refs zzet#674, zzet#672, zzet#685
|
CI was red on me, fixed in What failed
macOS failed on it and ubuntu was cancelled by fail-fast, which is why both test jobs read as failures. Mistake 1 — I checked the wrong ceilingBefore pushing I did go looking for the #685 hazard, found The PR description says "the compact tools/list is byte-identical at 14984 on both trees" as evidence the addition was free. It was evidence about a surface my change cannot reach. I have corrected the body. Mistake 2 — my isolation measured my own codeWhen I tried to confirm the cost after the CI failure, I reverted the schema file and re-ran: and got 97671 again — identical to my branch. I nearly reported that as "the failure is pre-existing on main". It is not: Checking out Worth recording because the earlier isolation runs in this PR were sound only by accident — those changes were still uncommitted, where The fixAvailable headroom on main is 62 bytes. The description now carries only what an agent cannot learn anywhere else before calling: 41 bytes, landing at 97479. The rest of what I had put there — how to widen, and that a If you would rather not spend 41 of the 62 remaining bytes on this at all, say so and I will drop the schema line entirely; the flag is discoverable from the response either way. That is a budget call, not a correctness one. For #685Fresh numbers, since this is the second time in one day I have had to reverse-engineer a budget from a log line:
Nothing surfaces either number until CI is red, and neither failure message names the headroom — only a total that has to be subtracted by hand against a baseline stated in the message. I have added this measurement to #685. |
Closes #674, and the ceiling half of #672.
The zero-cost failure
search_textis described as the alt grep backbone, and a result bound bylimitwas byte-indistinguishable from a complete one:countislen(enriched), so the array and the count corroborated each other at the ceiling. On the repo in #672 that iscount: 1000against agit greptruth of 8963, with no flag, no cursor, and nothing else in the payload to doubt.The byte budget has always disclosed its own truncation (
_truncated_by_budget). The limit path had no equivalent.What a truncated response now carries
{ "count": 1000, "_truncated_by_limit": true, "_limit_applied": 1000, "count_is_exact": false, "_limit_requested": 100000, // only when the ceiling, not the caller, chose it "truncation_note": "…" }The note follows the
contract_tier_unbuiltshape that already works elsewhere in this codebase: say what the number means, say how to widen, and rule out the widening that looks obvious but does not work. Here that last part ispath: the filter runs over what survived truncation rather than over the corpus, so slicing a subtree returns whatever was left of the global cut. #672 lists that as one of three workarounds it had to rule out by hand.The ordering is the fix
rawMatchesis counted before the path and scope filters, and that is the substance rather than an implementation detail:A response holding 952 matches can be a truncated 1000. Measuring after the filters would miss exactly the case a caller cannot detect from the outside — and the 952 / 998 rows in #672's table are that shape, which is why the issue notes that even
len(matches) == 1000is not a reliable client-side detector.TestSearchText_TruncationIsMeasuredBeforeThePathFilterpins it with a fixture where the count necessarily lands below the limit: four matching files,limit: 3, filtered to a directory holding two. Whichever three the searcher returns, at most two survive, and the disclosure must still fire.Landing on the effective limit is the signal, so this can fire on a corpus holding exactly
limitmatches. A spurious "verify this" is the safe direction to be wrong in.The ceiling
GORTEX_SEARCH_TEXT_MAX_LIMIT, defaulting to 1000 so no existing caller's response changes shape. An unset, unparseable or non-positive value keeps the default rather than lifting the bound — a typo must not become an unbounded scan.Raising the ceiling without the disclosure only moves the silent cliff, which is why they land together. This is why I marked #674 as the one this closes and #672 as refs: the flag is the fix, the override is the affordance.
Tests
Five end-to-end cases through the handler and tables over both pure helpers. Ten mutants, each failing its test and passing restored: the flag never published ·
count_is_exactalways true · landing exactly on the limit not counted as truncation · truncation measured after the filters · the clamped request not disclosed · the disclosure riding on every response · the ceiling override ignored · an unusable ceiling value honored · the note dropping the recovery that does not work · the applied limit not reported.Three of those initially came back as "nothing ran" rather than as failures — each mutation orphaned a variable and the package stopped compiling, which is a void result, not a dead mutant. They were re-run in compile-clean form (
if _ = rawMatches; …) so all ten are real.Verification
go build ./...clean; theSearchTextsuite passes.Two measurements worth reporting rather than asserting:
The schema addition spends 41 of the 62 bytes the core preset has left. My first version spent 233 and turned CI red on
TestToolsListByteCeilings(coreat 97671 against a 97500 baseline;mainsits at 97438). The description now carries only the part an agent needs before calling — that a bound result sets_truncated_by_limit— and the recovery detail rides on the response intruncation_noteinstead. Both ceiling tests pass at 97479.I had originally checked
TestCompactToolsListIsStaticAndBudgetedinstead, measured 14984 bytes on both trees, and reported that as evidence the addition was free. That number is about the facade surface, whichsearch_textis not on; it was the wrong ceiling. Headroom as measured onmain: 62 bytes on the core preset, 16 bytes on the facade list — both recorded on #685.One unrelated test is order-dependent.
TestSearchTextRefusalIsTheCapabilityEvaluationsRefusalfailed for me, and I did not call it flaky until I had the mechanism: on a cold ref-view build it getsview_building: … retry after 2sinstead of thecapability_unavailablerefusal it asserts, and on the next run — the build now cached under a content-hashed id that survives the per-testt.TempDir()— it passes. I reproduced both states on my branch and confirmed the pristine tree passes only in the warm state. Nothing in this change touches view building. Filing it separately.🤖 Generated with Claude Code