Skip to content

fix: cap adversarial-review prompt at 800KB with UTF-8-safe fallback chain#314

Open
cardene777 wants to merge 1 commit into
openai:mainfrom
cardene777:feature/1467-adversarial-prompt-cap
Open

fix: cap adversarial-review prompt at 800KB with UTF-8-safe fallback chain#314
cardene777 wants to merge 1 commit into
openai:mainfrom
cardene777:feature/1467-adversarial-prompt-cap

Conversation

@cardene777
Copy link
Copy Markdown

Summary

Heavy-tier adversarial-review invocations crash with Input exceeds the maximum length of 1048576 characters. when buildAdversarialReviewPrompt interpolates a 256KB diff alongside the standard template. This is the follow-up scenario surfaced after PR #179 (256KB inline-diff cap) — that fix prevents ENOBUFS, but the assembled prompt can still exceed the Codex thread input cap on its own.

This PR adds a hard MAX_PROMPT_BYTES = 800 * 1024 byte cap on the assembled prompt (template + placeholders + content), plus a 3-step fallback chain that always stays under the cap.

Changes

plugins/codex/scripts/codex-companion.mjs

  • buildAdversarialReviewPrompt is now exported and wraps its output in a 3-step fallback chain:
    1. Initial render — return verbatim if Buffer.byteLength(prompt, "utf8") <= MAX_PROMPT_BYTES.
    2. Lightweight fallback — swap guidance to "The repository context below is a lightweight summary. Inspect the target diff yourself..." (mirrors buildAdversarialCollectionGuidance({ includeDiff: false })) and rebuild REVIEW_INPUT from summary / changedFiles / fileCount (no diff body).
    3. Hard truncation — compute byte budget, walk back from UTF-8 continuation bytes ((buf[end] & 0xc0) === 0x80) to preserve valid sequences, and append \n\n[content truncated to fit prompt size limit]\n so the reviewer model can see truncation occurred.
  • isDirectExecution() wraps main() so that importing the module from tests no longer runs the CLI as a side effect. Comparison uses fs.realpathSync.native() on both process.argv[1] and fileURLToPath(import.meta.url) so symlinked install paths (plugin cache, macOS /var vs /private/var) still match the entry. Lexical comparison remains as a fallback when realpath throws.

tests/codex-companion.test.mjs (new)

Five node:test cases covering:

# input asserts
1 11-byte content verbatim interpolation, cap not triggered
2 content sized so the prompt is exactly 800 KB Buffer.byteLength === 800 * 1024, full content preserved
3 1 MB x content size ≤ 800 KB, lightweight guidance present, original content not embedded
4 5 MB x content size ≤ 800 KB, output contains truncated marker
5 750 KB content byte-level (not code-point) accounting works, multi-byte character preserved

Test plan

  • node --check plugins/codex/scripts/codex-companion.mjs
  • node --test tests/codex-companion.test.mjs → 5 / 5 pass
  • node --test tests/*.test.mjs → 82 / 91 pass (9 pre-existing failures on main 807e03a: 5 in tests/runtime.test.mjs setup …, 3 in tests/commands.test.mjs status … / result …, 1 in tests/state.test.mjs resolveStateDir … — all reproduce on a clean upstream checkout and are unrelated to this PR)
  • CLI smoke: node plugins/codex/scripts/codex-companion.mjs --help prints usage, confirming main() still runs through isDirectExecution()

Backward compatibility

  • buildAdversarialReviewPrompt(context, focusText) keeps its existing 2-argument signature; the only existing caller (codex-companion.mjs line ~409, const prompt = buildAdversarialReviewPrompt(context, focusText)) is unchanged.
  • collectReviewContext() return shape is untouched; the lightweight branch reads optional summary / changedFiles / fileCount fields it already populates.
  • Existing inline / self-collect diff modes from PR fix: avoid embedding large adversarial review diffs #179 are preserved — the new cap only kicks in when the assembled prompt would exceed 800 KB.

Known follow-ups (out of scope for this PR)

These were surfaced during review and intentionally deferred:

  1. stop-review-gate-hook.mjs has the same uncapped-prompt risklast_assistant_message is interpolated into stop-review-gate.md and passed via process arguments without a byte cap. The fix shape is different (process argv vs inline string), so it warrants a separate PR.
  2. Lightweight fallback file-name sanitization — when changedFiles is rendered into the lightweight summary, paths are joined with \n. Git rejects newlines in tracked paths so the practical injection surface is small, but defense-in-depth (JSON-encode or fence the list) is worth a follow-up.
  3. Observability of fallback path — the function only returns the final prompt string, so operators can't see which fallback step ran. A future change could return { prompt, mode, originalBytes, finalBytes } and surface that in the job payload.

Notes on the constant

MAX_PROMPT_BYTES = 800 * 1024 (≈ 819 200 bytes) leaves a ~229 KB margin under the 1 048 576-character API cap. The API's limit is in code units while Buffer.byteLength(..., "utf8") counts bytes, so for ASCII the margin is the full 229 KB; for the (very rare) case of an all-multi-byte prompt the margin shrinks but never to zero with realistic content.

Closes cardene777/claude-config#1467

…chain

Heavy-tier adversarial reviews crash with `Input exceeds the maximum length
of 1048576 characters.` when `buildAdversarialReviewPrompt` interpolates
a near-256KB diff alongside the standard template (closes openai#11 follow-up).

This change adds a `MAX_PROMPT_BYTES = 800 * 1024` byte cap to
`buildAdversarialReviewPrompt` (now exported) and a three-step fallback
chain:

1. Initial render — return verbatim if within cap.
2. Lightweight fallback — switch guidance to "Inspect the target diff
   yourself" and replace `REVIEW_INPUT` with summary + changedFiles.
3. Hard truncation — UTF-8-safe byte truncation with explicit
   `[content truncated to fit prompt size limit]` marker.

The truncation routine walks back from UTF-8 continuation bytes (0x80-0xBF)
so multi-byte sequences are never split mid-character. `Buffer.byteLength`
is used throughout to measure UTF-8 bytes, not UTF-16 code units.

`isDirectExecution()` wraps `main()` so importing the module from tests
no longer runs the CLI as a side effect. Comparison uses
`fs.realpathSync.native()` on both `process.argv[1]` and
`fileURLToPath(import.meta.url)` so symlinked install paths (plugin cache,
macOS `/var` vs `/private/var`) still match. Lexical comparison remains as
a fallback when realpath throws.

Adds `tests/codex-companion.test.mjs` covering small / at-limit / 1MB
lightweight fallback / 5MB truncation / 750KB multibyte input.

Refs: cardene777/claude-config#1467
@cardene777 cardene777 requested a review from a team May 11, 2026 12:41
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.

1 participant