Skip to content

fix(llm): bound LLM prompt size for long diffs - #3438

Merged
max-sixty merged 3 commits into
mainfrom
prompt-size-long-diffs
Jul 13, 2026
Merged

fix(llm): bound LLM prompt size for long diffs#3438
max-sixty merged 3 commits into
mainfrom
prompt-size-long-diffs

Conversation

@max-sixty

@max-sixty max-sixty commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Generation prompts (commit message, squash message, branch summary) could exceed model context limits: a real-world failure hit 210k tokens against a 200k window. prepare_diff passed any diff under 400k bytes through whole (syntax-dense content tokenizes at roughly 2 bytes/token, so that alone can exceed 200k tokens), and past the threshold its output was still unbounded: the 50-lines-per-file truncation had no line-length cap (a single-line minified file passed through whole), the diffstat was never reduced, and the squash prompt appended one subject line per squashed commit without limit.

The mechanism now: the stat is capped at 200 lines. Diffs at or under a 100k-byte budget pass through unchanged; over budget, lock-file sections are dropped, then sections are truncated (50 lines per file, 500 bytes per line) and accumulate in diff order until the budget is spent, with inline omission markers pointing at the diffstat. The squash commit list is capped at 200 subjects with a synthetic "(N earlier commits omitted)" entry. Worst case lands around 30k tokens, against 100k+ below the old threshold and unbounded above it.

Also fixed while touching this surface: all four prompt-diff invocations now share one DIFF_PREFIX_OVERRIDES constant forcing the a//b/ prefix format (diff.noprefix, diff.mnemonicPrefix, and git >= 2.45's diff.srcPrefix/diff.dstPrefix), so section parsing can't be defeated by user git config; the summary path previously carried no guard at all, and an over-budget diff there collapsed to an empty prompt diff.

Options considered: only lowering the 400k threshold keeps the cliff and both unbounded outputs; ranking files by relevance is an unbounded path-pattern heuristic; map-reduce summarization multiplies LLM calls on a user-facing wait; shrinking -U context degrades the small prompts that dominate; piping the diff into the LLM (the existing TODO(diff-pipe)) addresses process memory rather than tokens and stays open.

Behavior notes: diffs between 100k and 400k bytes are now truncated where they previously passed through whole (that window was the failure mode); truncated sections are LF-normalized; the summary cache keys on the raw diff, so existing entries stay valid.

This was written by Claude Code on behalf of max

max-sixty and others added 2 commits July 12, 2026 15:41
Commit-message, squash, and summary prompts embedded diffs nearly
unbounded: under the 400k threshold the diff passed through whole, which
for syntax-dense content (hashes, minified assets, ~2 bytes/token) can
exceed a 200k-token context window outright. Past the threshold, the
50-lines-per-file truncation had no line-length cap, so a single-line
minified file passed through whole, and the diffstat was never reduced
at all.

prepare_diff now caps the stat at 200 lines (head plus trailing summary
line), passes diffs at or under 100k bytes through unchanged, and
otherwise drops lock files then accumulates per-file-truncated sections
(50 lines, 500 bytes per line) in diff order until the budget is spent.
The accumulation loop is the output bound; the per-section caps keep any
one file from eating the whole budget. Worst case is ~30k tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Cap squashed-commit subjects at 200 per prompt, with a synthetic
  "(N earlier commits omitted)" entry standing in for the older tail:
  the diff budget did not bound the squash prompt's commit list.
- Drop truncate_stat's last-line special case: the summary path
  concatenates two --stat blocks (branch + working tree), so "the last
  line is the total" does not hold; keep head plus omission marker.
- Force a/ b/ diff prefixes in compute_combined_diff so summary diffs
  stay parseable into sections under diff.noprefix, the same guard the
  commit and squash paths already carry.
- Render sections without the full-section Vec or per-line allocations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.

The rewrite reads cleanly and the accumulation/per-section caps look correct: a hunk's @@ marker always lands in the first few lines so a truncated section is bounded to ~MAX_LINES_PER_FILE * MAX_LINE_LEN, which guarantees the first section always fits under the budget; the squash-commit slicing and char-boundary truncation are sound. One thing worth a look:

The prefix guard doesn't cover diff.srcPrefix / diff.dstPrefix (git ≥2.45). The added comment says the -c flags "force the a//b/ prefix format regardless of user git config," but diff.noprefix=false + diff.mnemonicPrefix=false don't neutralize a user's diff.srcPrefix / diff.dstPrefix. Verified on git 2.54:

$ git -c diff.srcPrefix=SRC/ -c diff.dstPrefix=DST/ \
      -c diff.noprefix=false -c diff.mnemonicPrefix=false diff | head -1
diff --git SRC/foo.txt DST/foo.txt

parse_diff_sections splits on " b/", so for such a user every section fails to parse → filtered_sections is empty → an over-budget diff collapses to an empty diff sent to the LLM. It's niche config, but the dropped budget (400k→100k) makes the over-budget path more reachable, and this is the exact path the guard exists to protect. Pinning the prefixes explicitly closes it and is safe on older git (unknown -c keys are ignored):

$ git -c diff.srcPrefix=a/ -c diff.dstPrefix=b/ ... diff  # forces a/ b/ back

The inline suggestion below fixes the branch-diff site. The same guard is incomplete at the other three spots that copy this pattern — the working-tree diff just below (repo.run_command(&["-C", &path, "-c", "diff.noprefix=false", ...])) and the two pre-existing sites in src/llm.rs (build_commit_prompt / build_squash_prompt) — worth applying there too, or factoring the flag list into one shared constant so it can't drift.

Comment thread src/summary.rs Outdated
Review caught that git >= 2.45's diff.srcPrefix/diff.dstPrefix bypass
the diff.noprefix/diff.mnemonicPrefix overrides, so a user setting them
still defeats parse_diff_sections and an over-budget diff collapses to
an empty prompt diff. All four prompt-diff invocations now share one
DIFF_PREFIX_OVERRIDES constant pinning all four keys; unknown config
keys are ignored by older git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@max-sixty
max-sixty marked this pull request as ready for review July 13, 2026 00:36
@max-sixty
max-sixty merged commit c0b889a into main Jul 13, 2026
40 checks passed
@max-sixty
max-sixty deleted the prompt-size-long-diffs branch July 13, 2026 22:58
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