Skip to content

Commit 69d2b2b

Browse files
authored
Merge pull request #32 from hotdata-dev/fix/escaped-prompt-budget
fix(review): measure the prompt budget in escaped bytes
2 parents c05858c + 65f3552 commit 69d2b2b

2 files changed

Lines changed: 241 additions & 32 deletions

File tree

scripts/gather-review-context.sh

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,53 @@ set -eo pipefail
2626
: "${PR_NUMBER:?the calling step must set PR_NUMBER}"
2727
: "${REPO:?the calling step must set REPO}"
2828

29+
# How many bytes a file occupies once JSON-escaped. Defined here, above the budget
30+
# block, because the budget is denominated in escaped bytes -- see PROMPT_ARG_LIMIT
31+
# below for why that is the unit and not raw bytes.
32+
#
33+
# `jq -Rs .` reads the whole file as one JSON string and emits it quoted, which is the
34+
# transformation toJson() applies to the prompt input. Measured against the run that
35+
# failed, jq came within 0.5% and on the high side, which is the side to be wrong on.
36+
#
37+
# head -c cuts on a byte boundary and so can split a UTF-8 character. jq does not fail
38+
# on that; it substitutes U+FFFD, three bytes where the fragment was one or two, so a
39+
# mid-character cut can only over-report. The fallback is for a jq that is missing or
40+
# refuses outright: two bytes per input byte is what a file of nothing but quotes and
41+
# newlines costs, and over-estimating only trims more.
42+
escaped_bytes() {
43+
local n=''
44+
n=$(jq -Rs . < "$1" 2>/dev/null | wc -c | tr -d ' ') || n=''
45+
case "$n" in
46+
'' | *[!0-9]*) n=$(( $(wc -c < "$1" | tr -d ' ') * 2 )) ;;
47+
esac
48+
printf '%s' "$n"
49+
}
50+
2951
# Caps. The median PR reviewed across the org is 161 changed lines and the largest
3052
# in a week was 3,448, so 3,000 patch lines covers the corpus; the cap exists so
3153
# one generated-file PR cannot blow up the prompt.
3254
DIFF_MAX=3000
3355
SINCE_MAX=2000
3456
LOG_WINDOW=120
57+
# The two diff blocks share one line budget rather than holding independent caps.
58+
# They overlap by construction: the since-last-review diff is a subset of the full
59+
# diff, exactly equal to it on a single-file PR, and DIFF_MAX + SINCE_MAX let the pair
60+
# reach 5,000 lines of largely the same patch. The dashboard PR named below carried 42
61+
# KB of "since your last review" stacked on 66 KB of "full diff" -- the same file,
62+
# twice -- and the pair is what put the prompt over the limit.
63+
#
64+
# The budget below bounds what that costs, but bounding it is not the same as not
65+
# spending it: every line the duplicate takes is a line of the budget the rest of the
66+
# context does not get. So the pair is capped together and the full diff is the block
67+
# that yields, because on cycle 2+ what changed since the last round is the reviewer's
68+
# subject and the full patch is one allowlisted `gh pr diff` away. Cycle 1 has no
69+
# since-diff, so the full diff keeps very nearly the whole budget.
70+
#
71+
# No floor is written under the full diff because SINCE_MAX sits below this number:
72+
# the since-diff can spend at most 2,000 of the 3,000 lines, so the full diff always
73+
# keeps the remaining 1,000. A floor would be a branch no input reaches. Raising
74+
# SINCE_MAX to meet or exceed this number is the change that would need one.
75+
DIFF_BUDGET_LINES=3000
3576
# Byte budgets. Both of this step's outputs are interpolated into the SAME `prompt:`
3677
# string in the review step, and that string reaches the reviewer as one environment
3778
# variable -- so the binding limit is the kernel's MAX_ARG_STRLEN, 32 * PAGE_SIZE =
@@ -46,16 +87,36 @@ LOG_WINDOW=120
4687
# The earlier note here reasoned about the runner's UTF-16 accounting of step outputs.
4788
# That limit is real and separate; it is not the one that fails first.
4889
PROMPT_ARG_LIMIT=131072
90+
# Every budget below counts *escaped* bytes, because the environment variable that
91+
# fails first does not hold the prompt as written. claude-code-action's action.yml
92+
# sets `ALL_INPUTS: toJson(inputs)` on the step it runs, so the prompt is carried
93+
# twice: once raw as PROMPT, and once JSON-escaped inside ALL_INPUTS. The escaped copy
94+
# is always the larger of the two, so it is always the one that reaches the limit
95+
# first, and bounding the raw string leaves the real one unbounded.
96+
#
97+
# A Grafana dashboard PR is the proof: 123,401 raw bytes -- inside the limit -- and
98+
# 135,366 escaped, and it failed on two consecutive pushes. A budget denominated in
99+
# raw bytes does not see it at all.
100+
#
101+
# The expansion is not a constant that could be folded in as a factor. Prose costs
102+
# about 1.02x and a quote-and-newline dense JSON diff about 1.20x, so the same 3,000
103+
# lines land either side of the limit depending only on what the file holds. Hence
104+
# every measurement below runs through escaped_bytes.
105+
#
106+
# What toJson(inputs) serializes beside the prompt: 38 further inputs, measured at
107+
# 1,356 bytes on the run that failed. They are inside the same variable and spend the
108+
# same limit. Rounded up.
109+
ALL_INPUTS_OTHER_BYTES=2000
49110
# What the workflow wraps around the two outputs: 494 bytes of literal header and the
50111
# two data-tag blocks with their do-not-follow notices, plus the interpolated REPO,
51112
# PR number and cycle. Rounded up.
52113
PROMPT_WRAPPER_BYTES=600
53114
# The static prompt is appended to that same string and spends the same budget, so it
54115
# is measured rather than hard-coded: editing the prompt document must shrink what is
55-
# left for context, not silently overflow the limit.
116+
# left for context, not silently overflow the limit. Measured escaped, like the rest.
56117
PROMPT_DOC="$(dirname "$0")/../docs/claude-pr-review-prompt.md"
57118
if [ -r "$PROMPT_DOC" ]; then
58-
PROMPT_DOC_BYTES=$(wc -c < "$PROMPT_DOC" | tr -d " ")
119+
PROMPT_DOC_BYTES=$(escaped_bytes "$PROMPT_DOC")
59120
else
60121
# A moved path or a narrowed sparse-checkout pattern. Assume large rather than
61122
# assume nothing: an over-generous figure truncates context, a missing one puts the
@@ -64,10 +125,11 @@ else
64125
echo "::warning::Could not measure ${PROMPT_DOC}; assuming ${PROMPT_DOC_BYTES} bytes when sizing the prompt budget."
65126
fi
66127
# 1 KB of slack. Deliberately small: every byte held back here is context the reviewer
67-
# does not get, and the three terms above are measured rather than estimated. The
68-
# budget lands near 122 KB, so a pull request that assembles under the limit today is
69-
# not newly truncated -- only the ones that already fail outright change behaviour.
70-
PROMPT_BUDGET=$((PROMPT_ARG_LIMIT - PROMPT_WRAPPER_BYTES - PROMPT_DOC_BYTES - 1024))
128+
# does not get, and the terms above are measured rather than estimated. A pull request
129+
# that assembles under the limit today is not newly truncated -- only the ones that
130+
# already fail outright change behaviour.
131+
PROMPT_BUDGET=$((PROMPT_ARG_LIMIT - ALL_INPUTS_OTHER_BYTES - PROMPT_WRAPPER_BYTES \
132+
- PROMPT_DOC_BYTES - 1024))
71133
# threads keeps a cap of its own so a comment dump cannot eat the budget the diff
72134
# needs: 400 inline comments rendered 1.1 MB on their own. It is also held to half the
73135
# budget, so a long review history can never starve the diff completely.
@@ -102,6 +164,47 @@ cap_file() {
102164
fi
103165
}
104166

167+
# cap_file_escaped <file> <budget> <notice> -- trim <file> until it fits <budget> once
168+
# escaped. The raw cut point is found by measuring, scaling and re-measuring rather
169+
# than by assuming a ratio, because the ratio is a property of the content: the same
170+
# 3,000 lines cost 1.02x as prose and 1.20x as JSON. Escaped size is monotonic in raw
171+
# length, so scaling by how far over budget the file is converges downward. Two or
172+
# three passes is typical on real input; the loop bound is a backstop, not the
173+
# mechanism.
174+
cap_file_escaped() {
175+
local file=$1 budget=$2 notice=$3 esc raw target i=0
176+
# The notice is appended after the cut, so its bytes come out of the budget first. A
177+
# cap that put the file back over the limit by announcing itself would be the same
178+
# bug in miniature.
179+
budget=$((budget - 300))
180+
if [ "$budget" -lt 1 ]; then budget=1; fi
181+
esc=$(escaped_bytes "$file")
182+
if [ "$esc" -le "$budget" ]; then return 0; fi
183+
while [ "$i" -lt 8 ]; do
184+
raw=$(wc -c < "$file" | tr -d ' ')
185+
# The 0.98 is deliberate undershoot: the ratio is measured over the whole file but
186+
# applied to a prefix, and a prefix denser than the average would otherwise land
187+
# just over and spend another pass.
188+
target=$(awk -v r="$raw" -v e="$esc" -v b="$budget" \
189+
'BEGIN { t = int(r * b / e * 0.98); print (t < 1) ? 1 : t }')
190+
head -c "$target" "$file" > "$file.cut"
191+
mv "$file.cut" "$file"
192+
esc=$(escaped_bytes "$file")
193+
if [ "$esc" -le "$budget" ]; then break; fi
194+
i=$((i + 1))
195+
done
196+
# Drop the partial line the byte cut left behind. A context ending in `"range": tru`
197+
# puts a mangled fragment of a patch line where the reviewer reads patch lines, and
198+
# it is the last thing before the notice. Guarded on there being an earlier boundary
199+
# to fall back to: a block that is one enormous line -- a base64 CI log dump is how
200+
# that happens -- has none, and losing all of it would cost more than ending
201+
# mid-line. Removing a line only shrinks the file, so the budget still holds.
202+
if [ "$(awk 'END {print NR}' "$file")" -gt 1 ]; then
203+
if sed '$d' "$file" > "$file.cut"; then mv "$file.cut" "$file"; fi
204+
fi
205+
echo "($notice)" >> "$file"
206+
}
207+
105208
fetch_raw() {
106209
RAW_OUT=$1
107210
shift
@@ -213,8 +316,8 @@ strip_block_tags() {
213316
# tag behind: there are none left for it to bisect.
214317
THREADS_FILE="${RUNNER_TEMP}/threads.md"
215318
printf '%s\n' "$THREADS" | strip_block_tags > "$THREADS_FILE"
216-
cap_file "$THREADS_FILE" "$THREADS_MAX_BYTES" \
217-
"prior review comments truncated at ${THREADS_MAX_BYTES} bytes; read the rest with gh pr view"
319+
cap_file_escaped "$THREADS_FILE" "$THREADS_MAX_BYTES" \
320+
"prior review comments truncated; read the rest with gh pr view"
218321

219322
DELIMITER="REVIEW_CONTEXT_$(openssl rand -hex 16)"
220323
{
@@ -362,6 +465,11 @@ done
362465
# because inline comments and the round's verdict are separate review objects.
363466
LAST_REVIEW_JQ='[.[][] | select(.user.login == "claude[bot]") | select(.submitted_at != null) | {commit_id, submitted_at}] | sort_by(.submitted_at) | last | (.commit_id // "")'
364467
LAST_SHA=$(printf '%s' "$REVIEWS" | jq -s -r "$LAST_REVIEW_JQ" 2>/dev/null) || LAST_SHA=''
468+
# Lines this block spends, which the full diff below subtracts from the shared budget.
469+
# It stays 0 on every path that renders no patch -- cycle 1, a rebase, a failed fetch
470+
# -- so the full diff gets the whole budget exactly as it did before there was a
471+
# since-diff to share with.
472+
SINCE_USED=0
365473
if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SHA" ]; then
366474
SINCE_FILE="${RUNNER_TEMP}/since-last-review.diff"
367475
# The compare API, not git: the checkout is fetch-depth 1, so no base branch and
@@ -385,6 +493,8 @@ if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SH
385493
# awk, not `wc -l`: wc pads its count with spaces on BSD and the number
386494
# is interpolated into the notice below, not just compared.
387495
SINCE_LINES=$(awk 'END {print NR}' "$SINCE_FILE")
496+
SINCE_USED=$SINCE_LINES
497+
if [ "$SINCE_USED" -gt "$SINCE_MAX" ]; then SINCE_USED=$SINCE_MAX; fi
388498
{
389499
echo
390500
echo "## Diff since your last review (${LAST_SHA} to ${HEAD_SHA})"
@@ -406,6 +516,12 @@ if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SH
406516
fi
407517
fi
408518

519+
# Whatever the since-diff left of the shared budget, bounded above by DIFF_MAX so a PR
520+
# with no since-diff renders exactly what it always did. SINCE_MAX keeps the
521+
# subtraction from reaching zero; see DIFF_BUDGET_LINES above.
522+
FULL_DIFF_MAX=$((DIFF_BUDGET_LINES - SINCE_USED))
523+
if [ "$FULL_DIFF_MAX" -gt "$DIFF_MAX" ]; then FULL_DIFF_MAX=$DIFF_MAX; fi
524+
409525
DIFF_FILE="${RUNNER_TEMP}/pr.diff"
410526
if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
411527
DIFF_LINES=$(awk 'END {print NR}' "$DIFF_FILE")
@@ -419,9 +535,9 @@ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
419535
echo "The diff came back empty. That is unusual for a pull request; treat it"
420536
echo "as missing rather than as \"nothing changed\" and run gh pr diff."
421537
else
422-
head -n "$DIFF_MAX" "$DIFF_FILE"
423-
if [ "$DIFF_LINES" -gt "$DIFF_MAX" ]; then
424-
echo "(truncated: first ${DIFF_MAX} of ${DIFF_LINES} lines; run gh pr diff for the rest)"
538+
head -n "$FULL_DIFF_MAX" "$DIFF_FILE"
539+
if [ "$DIFF_LINES" -gt "$FULL_DIFF_MAX" ]; then
540+
echo "(truncated: first ${FULL_DIFF_MAX} of ${DIFF_LINES} lines; run gh pr diff for the rest)"
425541
fi
426542
fi
427543
} >> "$CTX"
@@ -447,7 +563,7 @@ fi
447563
# final size is known rather than assumed. The per-block caps above still matter -- they
448564
# decide *what* survives truncation, and they keep any one block from arriving here
449565
# having already crowded out the diff -- but this is what makes the total fit.
450-
THREADS_BYTES=$(wc -c < "$THREADS_FILE" | tr -d " ")
566+
THREADS_BYTES=$(escaped_bytes "$THREADS_FILE")
451567
CTX_MAX_BYTES=$((PROMPT_BUDGET - THREADS_BYTES))
452568
# Unreachable while THREADS_MAX_BYTES is clamped to half the budget. Kept because the
453569
# alternative if that clamp is ever loosened is `head -c` with a negative count, and an
@@ -462,11 +578,19 @@ fi
462578
# author-controlled, and this is the file they land in.
463579
strip_block_tags < "$CTX" > "$CTX.stripped"
464580
mv "$CTX.stripped" "$CTX"
465-
if [ "$(wc -c < "$CTX" | tr -d " ")" -gt "$CTX_MAX_BYTES" ]; then
466-
echo "::warning::Review context exceeded ${CTX_MAX_BYTES} bytes and was truncated."
581+
CTX_ESCAPED=$(escaped_bytes "$CTX")
582+
if [ "$CTX_ESCAPED" -gt "$CTX_MAX_BYTES" ]; then
583+
# A notice rather than a warning. No line cap bounds bytes: 3,000 lines of prose is
584+
# about 60 KB escaped and 3,000 lines of dashboard JSON about 200 KB, so a
585+
# generated-file PR reaches this legitimately and often, and a warning that cried
586+
# regression every time would stop being read. It is here so an operator reading a
587+
# thin review can see the context was cut and by how much -- and so a budget that
588+
# starts firing on ordinary prose PRs, which would mean something really did
589+
# regress, is visible rather than silent.
590+
echo "::notice::Review context reached ${CTX_ESCAPED} escaped bytes against a ${CTX_MAX_BYTES} byte budget and was truncated to fit the review prompt."
467591
fi
468-
cap_file "$CTX" "$CTX_MAX_BYTES" \
469-
"context truncated at ${CTX_MAX_BYTES} bytes; read what is missing with gh pr diff and gh pr view"
592+
cap_file_escaped "$CTX" "$CTX_MAX_BYTES" \
593+
"context truncated to fit the review prompt; read what is missing with gh pr diff and gh pr view"
470594

471595
CTX_DELIMITER="PR_CONTEXT_$(openssl rand -hex 16)"
472596
{

0 commit comments

Comments
 (0)