Skip to content

fix(review): bound the assembled prompt below MAX_ARG_STRLEN - #31

Merged
zfarrell merged 8 commits into
mainfrom
fix/bound-review-prompt-size
Aug 7, 2026
Merged

fix(review): bound the assembled prompt below MAX_ARG_STRLEN#31
zfarrell merged 8 commits into
mainfrom
fix/bound-review-prompt-size

Conversation

@anoop-narang

Copy link
Copy Markdown
Contributor

The problem

The gather step's two outputs — threads and pr_context — are interpolated into a single prompt: value, which reaches the reviewer as one environment string. That makes the kernel's MAX_ARG_STRLEN (32 * PAGE_SIZE, 131072 on the runners) the binding limit.

The budgets did not account for it. THREADS_MAX_BYTES=100000 and CTX_MAX_BYTES=200000 were applied independently and were free to sum to 300 KB. The comment above them reasoned about the runner's UTF-16 accounting of step outputs — a real limit, but not the one that fails first.

Past the limit, exec fails and the failure is near-silent:

##[error]An error occurred trying to start process '/usr/bin/bash' with working
directory '/home/runner/work/…'. Argument list too long
##[end-action id=review.run;outcome=failure;conclusion=failure;duration_ms=264]

264 ms — the reviewer never starts. The action reports success, the tool-usage steps skip for want of an execution log, no review of any kind is submitted, and the only trace is the generic "review unavailable, please review manually" notice. A pull request assembling 186,505 bytes of prompt failed exactly that way, with both blocks inside their own caps:

section bytes
header + tag wrappers 494
threads (within its 100 KB cap)
## Diff since your last review 39,514
## Full diff 119,178
static prompt document 7,396
total 186,505

Because the cap that would have truncated sat above the limit, the existing cap_file machinery and its ::warning:: notice never fired.

The fix

Budget the sum rather than two independent blocks:

  • PROMPT_BUDGET = 131072 − wrapper − prompt document − 1 KB slack
  • The prompt document is measured, not hard-coded, so editing it shrinks the context budget instead of silently overflowing the limit. If it can't be read (moved path, narrowed sparse-checkout), the script assumes a large value and warns — a generous guess truncates, a missing one restores the exec failure.
  • pr_context gets whatever threads did not spend.
  • threads keeps a cap of its own, since 400 inline comments rendered 1.1 MB on their own, held to half the budget so a long review history cannot starve the diff either.

Blast radius

The budget lands near 122 KB — deliberately close to the limit rather than comfortably below it. Every byte held back is context the reviewer doesn't get, and the three terms are measured rather than estimated. The consequence is that a pull request assembling under the limit today is not newly truncated; only those that already fail outright change behaviour.

Tests

The new assertion oversizes every input at once — long review history and oversized diff and long conversation. That combination is the bug: oversizing one input at a time is exactly what let the old pair of caps look safe, each block inside its own limit while the total did not fit.

Verified against the unfixed script:

pre-fix:  threads 100081 + context 181375 + wrapper 493 + doc 7396 = 289345, limit 131072  FAIL
post-fix: 130029 <= 131072                                                                 ok

The wrapper is measured out of the workflow rather than hard-coded, so adding a header line or another do-not-follow notice fails the test instead of quietly spending budget the script believes it has.

Two existing assertions were stale against a derived cap and are fixed:

  • one pinned the literal 200000 in the truncation notice;
  • one allowed 210,000 bytes of context — past the limit the prompt is bounded by, so it would have passed on a context that could not be handed to the reviewer at all.

Full suite passes (context-step, pr-context, review-cycle, tool-usage, workflow-lint). The workflow YAML is unchanged.

Not addressed here

On review cycle 2+ the prompt carries the diff twice — ## Full diff plus ## Diff since your last review (119 KB + 40 KB in the case above). The bound now keeps that within the limit, but the duplication is its own concern and is left alone.

This step's two outputs are interpolated into one `prompt:` value, which
reaches the reviewer as a single environment string. That makes the
kernel's MAX_ARG_STRLEN -- 32 * PAGE_SIZE, 131072 on the runners -- the
binding limit, not the step-output limit the previous budgets reasoned
about. Those budgets were 100 KB for threads and 200 KB for context,
applied independently and free to sum to 300 KB.

Past the limit exec fails with "Argument list too long", and the failure
is near-silent: the action still reports success, the tool-usage steps
skip for want of an execution log, no review of any kind is posted, and
the only trace is the generic "review unavailable" notice. A pull request
assembling 186 KB of prompt failed exactly that way with both blocks
inside their own caps.

So budget the sum. Context gets what the threads block did not spend, out
of the argument limit less the wrapper, the prompt document and 1 KB of
slack. The document is measured rather than hard-coded, so editing it
shrinks the context budget instead of silently overflowing the limit.
threads keeps a cap of its own -- 400 inline comments rendered 1.1 MB on
their own -- held to half the budget so a long review history cannot
starve the diff either.

The budget lands near 122 KB, close enough to the limit that a pull
request assembling under it today is not newly truncated: only those that
already fail outright change behaviour.

The new assertion oversizes every input at once, which is what the old
pair of caps could not be caught by -- each block sat inside its own
limit while the total did not fit. Two existing assertions were stale
against a derived cap: one pinned the literal 200000 in the truncation
notice, and one allowed 210000 bytes of context, which is past the limit
the prompt is bounded by and so would have passed on a context that could
not be handed to the reviewer at all.
@anoop-narang
anoop-narang requested a review from a team as a code owner August 6, 2026 11:47
@anoop-narang
anoop-narang requested review from shefeek-jinnah and removed request for a team August 6, 2026 11:47
Comment thread scripts/gather-review-context.sh Outdated
Comment thread scripts/gather-review-context.sh

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Blocking Issues

  • scripts/gather-review-context.sh:444-456 (and the threads cap at :210) — the budget is enforced before strip_block_tags, which is what actually produces the emitted output at :216 and :461. That substitution replaces a 12-byte opening pr_context tag with a 19-byte [block tag removed], so the string handed to the reviewer can be up to 1.58x the size the cap measured. cap_file also appends its notice after head -c, putting the assembled total at about PROMPT_BUDGET + 103 before any substitution fires, which leaves roughly 920 of the 1024 bytes of slack — about 130 tag occurrences across the diff, PR body, log excerpt and threads — before the total crosses MAX_ARG_STRLEN again. What it crosses back into is the near-silent failure this PR exists to close, and it is reachable on purpose: strip_block_tags exists because an author may write a closing pr_context tag into text this script renders, so flooding it now also costs the PR its review behind a green check.

    The new all-oversized assertion cannot catch this. The stub's diff is +line N and its comment bodies are padding text, so no substitution fires and the measured total equals the capped total.

Action Required

  1. Make the cap measure what is emitted: run strip_block_tags into the file before cap_file, for both $THREADS_FILE and $CTX, and have :216/:461 copy the already-stripped file out. Truncating mid-[block tag removed] is harmless, and no unstripped tag can survive a cut.
  2. Give the new assertion an input that exercises it — a stub diff or comment body carrying the closing tag repeated — otherwise the bound stays untested against the one input shape that can break it.

The rest of the change is sound: budgeting the sum, measuring the prompt document rather than pinning it, and replacing the 210,000-byte assertion that sat above the limit. One non-blocking note inline on PROMPT_WRAPPER_BYTES.

I have not run anything — CI was still queued or in progress when this review started, so nothing here is a claim about the suite's result.

The bound the previous commit added did not hold. Both blocks were capped,
but what reached the output was `strip_block_tags < "$FILE"`, and that
substitution grows the text: the shortest tag it matches, `<pr_context>`,
is 12 bytes and becomes a 19-byte placeholder. So the cap measured a
smaller string than the one emitted -- 1.58x smaller at worst.

With ~920 bytes of slack left after cap_file appends its notice, about 130
tag occurrences anywhere in the diff, the PR body, a CI log excerpt or the
inline comment threads put the assembled prompt back over MAX_ARG_STRLEN,
and back into the near-silent failure this is meant to close. It is
reachable on purpose: strip_block_tags exists precisely because author
text can contain these tags.

Strip into the file first, then cap, then emit the already-stripped file.
A cut can bisect `[block tag removed]`, which is inert; it can no longer
leave half of a live tag behind, because none are left to bisect.

The all-oversized assertion could not catch this -- padding text fires no
substitution, so the emitted size equalled the capped size. STUB_DIFF_TAGS
puts a tag on every added line, which is the only input shape that
separates the two. Verified: with the strip back in its old position that
assertion fails.

Also compare the measured wrapper against PROMPT_WRAPPER_BYTES, extracted
from the script rather than repeated in the test. It is the only one of the
three subtracted terms that is stated as a constant rather than measured,
so it is the only one that can go stale, and nothing was checking it --
the wrapper could have grown by the whole remaining slack before the total
assertion went red, naming the symptom instead of the cause.

And tell the reviewer what to do with a truncated block. The prompt
document said not to re-fetch what it was given, with an exception only for
a block that was empty or unreadable -- nothing for one that was cut short.
A truncated diff would have been reviewed as though complete, and an
approval formed on a partial diff reads to a human as coverage it does not
have. It is now told to fetch the remainder, to say at the top of the
review that its context was truncated, and not to approve on the strength
of a change it could not fully see.
@anoop-narang

Copy link
Copy Markdown
Contributor Author

Both addressed in c05858c.

Blocking — cap measured before the size-changing substitution. Correct, and the bound genuinely did not hold. Verified the mechanism rather than taking it on faith: <pr_context> is 12 bytes and becomes a 19-byte placeholder (+7 per occurrence), </pr_context> +6, while both prior_review_comments forms shrink — so the opening pr_context tag is the worst case at 1.58×, as described.

Taken exactly as suggested: strip_block_tags now runs into the file before cap_file, for both $THREADS_FILE and $CTX, and the emit sites cat the already-stripped file. A cut can bisect [block tag removed], which is inert, and no live tag can survive one because none are left to bisect.

Also right that the existing assertion could not catch it — padding text fires no substitution, so the measured total equalled the capped total. STUB_DIFF_TAGS=1 puts a tag on every added line, which is the input shape that separates them. It has verified teeth: with the strip back in its old position it fails, and passes at 130029 with the fix.

Nit — PROMPT_WRAPPER_BYTES estimated but never compared. Taken, including the better version: the constant is extracted from the script with sed rather than repeated in the test, so the two cannot drift. Verified by setting the constant to 400, which fails with the prompt wrapper measures 493 bytes against PROMPT_WRAPPER_BYTES=400.

One thing you did not raise, which your reasoning led me to. The prompt document told the reviewer not to re-fetch what it was given, with an exception only for a block that was empty or unreadable — nothing for one that was truncated. So this PR would have introduced a reviewer that reads a shortened diff and reports as though it were complete. It is now told to fetch the remainder, to state at the top of the review that its context was truncated, and not to approve on the strength of a change it could not fully see. That mattered more than the byte arithmetic: a silent partial review is worse than a loud failure.

Full suite passes. PROMPT_DOC grew 7396 → 8324 bytes with that addition and the budget absorbed it automatically, which is the case for measuring it rather than pinning it.

Comment thread tests/context-step-test.sh Outdated
Comment thread scripts/gather-review-context.sh
claude[bot]
claude Bot previously approved these changes Aug 6, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cycle-1 findings are fixed in the code. strip_block_tags now runs into $THREADS_FILE and $CTX before cap_file, with the emit sites reduced to cat, so the cap measures what is emitted; PROMPT_WRAPPER_BYTES is extracted from the script with sed and compared against the measurement rather than repeated in the test.

Re-derived the bound against the doc's new size rather than taking it on faith: budget 121124, threads clamped to 60562, the two truncation notices appended after the cut at ~80 and ~89 bytes, measured literal wrapper 493 against a declared 600. Total lands near 130058 against 131072.

The unprompted addition to the prompt document is the more valuable half of this round. A cap that truncates silently would have turned every overflow into a review reporting on a diff it never saw — worse than the exec failure, because it carries a green check and an approval. Telling the reviewer to fetch the remainder, to state the truncation at the top, and not to approve on a change it could not fully see is what makes the truncation path safe to reach at all.

Two nits inline, both non-blocking: the threads half of the reorder has no test that can see it (the stubbed comment bodies fire no substitution), and LOG_MAX_BYTES was sized against the old 200 KB context cap — at 40000 per excerpt, up to three jobs and two excerpts each, the CI log block can now exceed the whole budget and cut the diff entirely.

Not asserting anything about CI: Tests / test was still unreported when this review started.

@zfarrell

zfarrell commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Hey @anoop-narang — thanks for opening this. I had Claude look into it yesterday but didn't get a chance to finish. Looks like we both caught the root cause, and you found a case I'd missed (strip-before-cap — my branch had that bug too). But I think this PR is missing one thing: the budget counts raw bytes, and the limit applies to the escaped copy.

claude-code-action's action.yml sets ALL_INPUTS: toJson(inputs), so the step carries the prompt twice — once raw as PROMPT, once JSON-escaped inside ALL_INPUTS. The escaped copy is always bigger, so it's always what hits MAX_ARG_STRLEN first.

hotdata-dev/monopoly#1670 is the case that slips through. 123,401 raw bytes — inside the limit — 135,366 escaped. Failed twice. I ran your script against it:

PROMPT env var (raw):   124,499   limit 131,072 -> fits
ALL_INPUTS (escaped):   137,231   limit 131,072 -> OVER

Your cap never even fires — the context lands at ~115 KB, under your 121 KB budget — so it passes through untouched and still dies. Your 186 KB example doesn't show this because 186 KB blows the raw limit on PROMPT by itself; raw arithmetic explains that one fine.

And it can't be corrected with a fixed factor: prose escapes at ~1.02x, quote-dense JSON at ~1.20x, so the same 3,000 lines land either side of the limit depending only on content.

I put the delta in #32, based on your branch, so you can pull it in here:

  • budgets measured escaped, via jq -Rs . (same operation as toJson; within 0.5% and high on the failed run)
  • counts the 38 other inputs toJson writes beside the prompt — 1,356 bytes in the same variable, currently uncounted
  • the two diff blocks share one 3,000-line budget. Your bound keeps the duplicate legal, but every line it spends is a line the rest of the context doesn't get.

Same PR with those changes: ALL_INPUTS: 129,191 against 131,072 — fits. Your tests keep their shape; I switched the totals to escaped and added two cases. All five suites pass.

One thing worth your call: that leaves 1,881 bytes of margin, which follows from the 1 KB slack you chose deliberately. If claude-code-action ever adds an input, the allowance blows and we're back here. I left it alone since your reasoning is written down.

fix(review): measure the prompt budget in escaped bytes
# Conflicts:
#	tests/context-step-test.sh
Comment thread docs/claude-pr-review-prompt.md Outdated
claude[bot]
claude Bot previously approved these changes Aug 7, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both prior blocking items are fixed in code, and the escaped-byte work in 65f3553 holds up:

  • strip_block_tags now runs into the file before the cap for both blocks (scripts/gather-review-context.sh:324, :580), and both emit sites cat the stripped file, so the cap measures what is emitted.
  • THREADS_BYTES is measured after cap_file_escaped returns, so even a non-converged threads cap is absorbed into CTX_MAX_BYTES rather than escaping the total.
  • cap_file_escaped re-derives the ratio from the surviving prefix each pass, so the expensive-prefix case converges well inside the 8-pass bound; the 0.98 undershoot plus the 300-byte notice reserve covers the landing.
  • SINCE_USED stays 0 on every no-patch path and SINCE_MAX < DIFF_BUDGET_LINES keeps FULL_DIFF_MAX >= 1000, so head -n never sees 0 or a negative.
  • Summing escaped_of per block over-counts by ~3 bytes each against the concatenation, which is the safe direction.

One non-blocking nit inline on the truncation paragraph in docs/claude-pr-review-prompt.md: it quotes a notice string the script stopped emitting in c05858c.

Not verified: CI had not reported when this review started, so I am not claiming the suite passes.

Comment thread scripts/gather-review-context.sh
claude[bot]
claude Bot previously approved these changes Aug 7, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five prior threads are resolved in the code: strip-before-cap for both blocks, PROMPT_WRAPPER_BYTES extracted from the script and compared, STUB_THREAD_TAGS plus a threads-side block-tag assertion, a shared LOG_BUDGET across failing jobs, and the truncation notices as NOTICE_* constants the suite ties to the prompt document.

Verified the notice contract holds in both directions I can check statically: all four constants at scripts/gather-review-context.sh:171-174 are quoted verbatim in docs/claude-pr-review-prompt.md:14, and the sed extraction at tests/context-step-test.sh:777 matches all four, so notice_count reaches 4.

One nit left inline on the log budget — the per-excerpt clamp is unreachable for every value PROMPT_BUDGET can take, so the summary excerpt can spend the window's share. Not blocking.

CI: Tests / test and Claude PR Review / review had not reported at the time this context was gathered, so I am not claiming the suite passes.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior blocking issue is fixed: strip_block_tags runs before both caps (scripts/gather-review-context.sh:383, :637-638) and the emit sites now copy the stripped file, so the bound measures what is actually emitted.

This cycle's change holds up. cap_log_excerpt's optional third argument gives the summary min(LOG_BUDGET/4, LOG_REMAINING) and leaves the window the larger share, the zero-limit branch still routes around head -c 0, and the charge is taken after the cap so the notice counts against the allowance. Log excerpts are measured raw against a budget derived in escaped bytes, but cap_file_escaped "$CTX" bounds the total regardless, so that only shifts what survives rather than whether the prompt fits.

Sum arithmetic checks out: PROMPT_WRAPPER_BYTES=600 against ~503 escaped actual, ALL_INPUTS_OTHER_BYTES=2000 against 1,356 measured, leaving roughly 1.7 KB under the limit on top of the declared 1 KB slack. PROMPT_DOC resolves to the same .github-workflows/docs/claude-pr-review-prompt.md the workflow interpolates, so measuring it is measuring the right file.

CI checks were queued or in progress in the snapshot, so I have no result for the new window_bytes assertion.

No blocking issues.

@zfarrell
zfarrell merged commit 47ae686 into main Aug 7, 2026
3 checks passed
@zfarrell
zfarrell deleted the fix/bound-review-prompt-size branch August 7, 2026 03:32
@anoop-narang

Copy link
Copy Markdown
Contributor Author

@zfarrell Thanks for taking over this. I was running out of time yesterday and couldn't finish this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants