fix(openai): stop long sessions on OpenAI models dying with "Prompt is too long" - #167
Merged
Merged
Conversation
added 3 commits
September 3, 2026 16:42
…s too long" When a conversation gets long, Claude Code shrinks it by asking the model for a plain-text summary. It sends that request with every tool still attached and relies on the wording of the prompt alone to stop the model using them. OpenAI-family models ignore that and answer with a tool call instead — usually whichever tool the session has been leaning on, such as Bash. Claude Code refuses to run the call and allows only one turn, so the attempt returns no summary at all and is thrown away. Three throwaways in a row and it stops shrinking the conversation, which then grows until the session stops dead with "Prompt is too long". Inside a background agent or a `-p` run nothing ever resets that, so the run is finished. clodex already made these requests text-only, but only recognised one when the session happened to carry Claude Code's structured-output tool. Sessions without it — an ordinary interactive session, a subagent spawned without a schema — got no protection: 15 of 173 such requests in local traffic logs, including the session this was diagnosed from. clodex now recognises the request by its own wording, whatever tools the session carries, which also covers manual `/compact`. The wording has to open the message, not merely appear in it, so that a turn which quotes the instructions — a pasted prompt, an agent's report, a read of clodex's own source — keeps its tools. Tool definitions are still sent so the cached prompt prefix keeps matching, and a request that does not match is left exactly as it was.
bman654
added a commit
that referenced
this pull request
Sep 4, 2026
* origin/main: fix(openai): stop long sessions on OpenAI models dying with "Prompt is too long" (#167)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Long-running sessions on OpenAI models could quietly stop being able to shrink themselves and then
stop dead with "Prompt is too long", with no way to continue. This fixes that.
When a conversation gets long, Claude Code shrinks it by asking the model for a plain-text summary.
It sends that request with every tool still attached and relies on the wording of the prompt alone
to stop the model using them. OpenAI-family models ignore that and answer with a tool call instead —
usually whichever tool the session has been leaning on. Claude Code refuses to run the call and
allows only one turn, so the attempt comes back with no summary at all and is thrown away. Three
throwaways in a row and it stops trying, and the conversation grows until the session stops.
clodex already made these requests text-only, but only recognised one when the session happened to
be carrying Claude Code's structured-output tool. Sessions without it got no protection. clodex now
recognises the request by its own wording, whatever tools the session is carrying.
User-visible failure
A long-running background agent on
sol(ChatGPT/Codex OAuth route, default proxy bridge mode)answered three consecutive compaction turns with
Bashtool calls and then hard-blocked. Theinference log shows three orphan turns — output 731 / 852 / 737 tokens, last stream part
tool-input-delta/tool-call, the first atinputTokens: 199822withcacheRead: 0—interleaved with ordinary turns showing the normal
reasoning-delta → text-deltaprogression. Thecompaction fork skips the transcript, so every other turn in that window maps 1:1 to a recorded
assistant turn by output-token count while those three map to nothing. The agent ended ~8 minutes
later with a synthetic "Prompt is too long", having recorded zero compaction boundaries across 345
turns, while its parent session on a Claude model compacted normally.
The tool name is inference: no request body or tool name is logged, and the fork skips the
transcript. What is observed is that those three turns emitted tool calls and produced no summary.
Root cause
Verified against the Claude Code 2.1.259 bundle (extracted with
scripts/extract-cc-bundles.mjs;procedure in
.claude/docs/claude-code-internals.md):noToolsflag and empties
options.toolsonly when set; no compaction caller passes it.canUseTooldenies every call outright ("Tool use is not allowed during compaction") and allowsone turn, so a tool call burns the turn and returns no text. Blocking it at the provider is the
only thing that helps.
text block; without one the attempt returns
summarization produced empty response.var B4n = 3; the auto-compact entryreturns
{kind: "failure_breaker_open"}before making any API call. The counter lives in thequery loop's state — it resets on a successful compaction or on a fresh query invocation, so an
interactive user gets a new chance on their next prompt. A headless or subagent run is a single
invocation, so there it never resets, which is why the incident looked permanent.
StructuredOutput. That covers schema-bearing workflow/SDK agents and the internal hook agent,but nothing else. Census below.
Reachable on the supported default configuration: proxy bridge mode, a
clodex:<provider>:<model>id or saved alias routed to
openai/openai-oauth. Both bridge modes reach the detector —src/proxy.tsandsrc/server/router.tsare the only callers oftranslateRequest. Anthropicpassthrough does not go through this path and is unaffected.
How often the old detector missed
Census of every request in my local inference log whose final user message carries the compaction
header, joined to the WebSocket diagnostics ledgers for the tool list (194 requests, Claude Code
2.1.220–2.1.247):
StructuredOutput(already protected)diagnosticskeySo the old precondition held most of the time in this corpus, which is heavily skewed toward
schema-mode agents. It is one user's traffic on older releases — treat the 15 as proof the gap is
real and routinely hit, not as a general rate.
What the guard now covers
Both compaction prompt builders in the bundle emit the same header and reminder, so dropping the
tool precondition widens the guard from "auto-compaction in a schema agent" to every compaction
turn: automatic/reactive compaction, manual
/compact,/compactup-to-a-selected-message, andeach of those with custom "Additional Instructions". That is a deliberate behaviour change and it is
correct — every one of these paths demands an
<analysis>+<summary>plain-text answer andClaude Code refuses to execute a tool call on any of them, so none can legitimately need a tool
call. All five real builder outputs are exercised in the tests.
The change
src/sdk-adapter.ts: drop theStructuredOutputprecondition; renameisClaudeCodeStructuredOutputCompactRequest→isClaudeCodeCompactRequest; rewrite the doccomment, whose stated premise (StructuredOutput is the salient tool models grab) this incident
disproves. The header must open a text block rather than merely appear in one — see below.
.claude/docs/translation.md: record the invariant and both rules that keep it correct.Deliberately unchanged: tool definitions still go upstream so the cached prompt prefix keeps
matching; the
diagnosticsearly bail; the fail-open posture.Why the header is anchored
A local review panel showed that an unanchored two-substring match over-fires on turns that merely
quote the envelope, silently removing tools from a turn that needed them. The reachable shapes:
a user pasting the instructions; a subagent's report repeating them; and — the one that matters —
clodex's own
src/sdk-adapter.tsandtests/sdk-adapter.test.tsreaching the final user messageas text, via an
@-mention or Claude Code's post-compaction file restore. That would have madethe first turn after a successful compaction tool-less for anyone working on this repo, which is a
regression this PR would have introduced.
Every builder puts the header first and appends the reminder to the same string, so requiring
startsWithon some text block costs nothing. A cross-family refuter and a freshcounterexample-hunting pass both failed to construct a false negative: the two builders' five real
outputs all begin with the header byte-for-byte, and Claude Code's message normaliser appends
sibling blocks rather than concatenating into an existing block's text (when two adjacent text
blocks merge, it appends
"\n"to the previous block and keeps the incoming one separate).Tests
tests/sdk-adapter.test.ts,CLAUDE_CODE_ENTRYPOINT=cli, full file (never-tisolation).main. A compaction request from a shell-heavy session — toolsBash/Read/Grep, noStructuredOutputanywhere, envelope verbatim from the 2.1.259 bundle —asserts
toolChoice === 'none'. On unmodifiedmain:expected 'auto' to be 'none'.[tool_result, text](193 of 194 observed),[text, text](1 of 194), a trailing reminder block, and plain-string content. Manual-/compactand "Additional Instructions" variants are covered.
StructuredOutputcoverage is unchanged, so the narrower case cannot regress.auto/required/named tool choice; the envelope quoted inline;quoted in a text block beside a
tool_result; carried inside atool_result(aReadorcatof a file holding it); header and reminder split across different blocks; head-only;tail-only; assistant prefill; an envelope left behind in history; and three near-misses that
defeat any loosening of the marker constants.
Mutations (full-file run; all killed)
messages.at(0)instead of.at(-1)diagnosticsbail / drop therole === 'user'checktool_resultcontentFive of these survived earlier drafts and drove the negatives above.
Separately, a harness reconstructs both real builders from the bundle and runs all five outputs
through
translateRequestin three arrival shapes: 15/15none, and the same text quoted or insidea
tool_resultstaysauto.Detecting Claude Code prompt drift
The strict two-marker predicate above remains the only code allowed to force
toolChoice: "none". A separate, warning-only recognizer now reports plausible prompt drift. Itexamines only text blocks in the final user message, requires an imperative text-only/tool-free
opening at byte zero and the rejected-tool/only-turn sentence at line start, and ignores diagnostic
requests, tool results, split blocks, history, assistant prefills, quotes with a preamble, and
partial strict envelopes. It never changes tool choice. Every sighting reaches the trace log before
terminal deduplication or suppression, and notices use the parent-notice channel rather than writing
directly to stderr.
This recognizer is diagnostic only: when Claude Code changes the prompt, clodex still fails open,
tools remain enabled, and compaction can keep failing until clodex ships updated strict markers. A
START-changed/END-intact prompt is visible, as is a change to both markers that retains the central
anchor. START-intact/END-reworded drift is deliberately invisible at runtime because excluding
known strict headers prevents partial-envelope false positives; only the canary catches that shape.
Terminal warnings are capped at three distinct signatures per process, keyed by
cc_version(requests without a bounded version share
unknown-version), followed by one suppression line.Users are directed to
https://github.com/bman654/clodex/issues.The strict START and END strings now live in one TypeScript source used by both production and
scripts/probe-patch-mechanism.mjs. The canary runs that exact marker check against every platformbundle it successfully downloads before any host/container execution tier. A marker-only miss is
reported to both the human and the triage agent as compaction-prompt drift, not as a broken patch
site or a reason to roll back the patch. The instructions explain the OpenAI long-session impact,
ask the investigator to verify that the bundle reader still exposes both compaction builders, and
then direct them to update
src/claude-code-compact-prompt.tsfrom the new wording. Exact markerpresence catches removal or in-place rewording; it cannot detect a new third builder that leaves both
old strings in the bundle.
Additional discriminating evidence
handler and the endpoint server handler each write the sighting to their configured debug log.
Deleting either production
log: plogconnection fails its full route test.adapter test file: anchor truncation, opening-line bound, required text-only language, required
tool prohibition, tool-word proximity, verb boundary, accepted severity labels, final-user-only,
per-text-block matching, and START-header exclusion.
missing END, runs
scripts/probe-patch-mechanism.mjs --json, and requires exit 1, a failedcompact-prompt-markerscheck namingend, and the autonomous triage guidance.the full probe exit 1 with only
compact-prompt-markersfailed; the pristine executable passed.Disabling the marker verdict made that mutation pass, proving the check decides the result.
probe=error / execution=pass, preserves infrastructure reasons whenan execution failure wins status precedence, excludes those lines from release evidence, and
proves generated
MODE=proberuns with isolated home/config/install paths. It also renders thereal investigation prompt with stdin closed, verifies every downloaded row survives into its
reproduction loop, and pins the marker-only human alert category. The corresponding deletion and
precedence mutations fail the self-test.
Runtime evidence
Live A/B through a real gateway against a real model —
clodex server --endpoint --quick --no-discovery, GPT-5.6 Luna over the ChatGPT OAuth route, macOS, Node v24.14.1. Request: ashell-heavy history with the verbatim compaction envelope merged into the final
tool_resultturn,tools
Bash/Read/Grep, noStructuredOutput.The two arms send byte-identical model input. The only difference is a top-level
diagnosticskey, which clodex reads at exactly one place — this detector's early bail — and never forwards
upstream. So the model sees the same bytes and only clodex's decision changes.
tool_choiceis{type: "any"}so the guard-off outcome is unambiguous.main(pre-fix)Bash, 0 chars of textmain(pre-fix)Bash, 0 chars of text — the guard cannot fire withoutStructuredOutputBash, 0 chars of textAnd with the envelope merely quoted (
"Explain this envelope to me:\n" + envelope), this branchreturns a
Bashcall 3/3 in both arms — the anchoring holds end-to-end, not just in unit tests.Smoke test on the default proxy mode, both legs:
clodex claude -- --model haiku -p …→HAIKU-OK;--model luna→MODEL-OK.Latest gate:
pnpm typecheck && pnpm test && pnpm buildgreen — 105 files / 2093 tests on Nodev24.14.1, with
CLAUDE_CODE_ENTRYPOINT=cli, a throwawayCLODEX_HOME, and bothHTTP_PROXYandHTTPS_PROXYset tohttp://127.0.0.1:9so no ambient bridge could mask a failure.What I could not verify
tool_choice: autoand no forcing, Luna answered the compaction prompt with text in every trial — at a ~2k-token
context and again at a measured 72,518-token context, pre-fix, where the guard could not fire.
The A/B above proves the guard's mechanism end-to-end; it is not a reproduction of the
model's disobedience. That the failure happens in the wild at ~200k tokens rests on the incident
log alone. I did not get to 200k.
tool_choice: {type: "any"}is a shape Claude Code never sends — every compaction call sitepasses no tool choice at all. It is a forcing function I chose to make the control unambiguous,
and
diagnosticson a compaction request is likewise a test lever (0 of 194 real ones carry it).The code path is identical either way, but the staging is mine, not Claude Code's.
--endpointmode, while the fix's primary target is the defaultproxy mode. The translation path is shared and the proxy smoke legs pass, but no proxy-mode
request-shape difference could have been caught by this experiment.
disobedience, which I am not making.
SDK-translated providers. The change is provider-independent, but that is reasoning, not
measurement.
anchor and both strict markers were checked in 27 extracted bundles spanning 2.1.238–2.1.260,
including all eight platform builds for 2.1.257 and 2.1.260. This is historical evidence, not a
promise that future wording stays fixed.
merge/error/reproduction behavior is covered by 132 checkout-specific self-tests, invoked as
canary/clodex-patch-canary-selftest.sh canary/clodex-patch-canary.sh, and the full marker checkhas the real single-binary mutation above, but a new production matrix remains unmeasured.
Failure and rollback
The strict detector fails open: a request whose final user message does not open a text block with
the exact header and reminder keeps its original tool choice. The loose recognizer can warn but
cannot make the request text-only, so prompt drift still degrades to the pre-fix compaction failure
until the marker source is updated. Reverting the commits restores the previous detector and removes
the warning/canary backstop.
Concurrency
Branched from
mainat09d3cdb, then merged currentorigin/mainat068273cafter the follow-upcommit. That merge brought in #171's provider timeout/retry work, #175's Claude Code 2.1.260 patcher
fix, and the 2.9.0 release commit from #176. Its one textual conflict, in
.claude/docs/translation.md, was resolved by retaining both the compaction invariant and the newtimeout guidance; the combined tree passed the full gate above plus all 132 canary self-tests.
Open PRs #48 and #101–#104 mechanically overlap route, adapter, or test files, but those branches
have not been updated since July or August and already need reconciliation with current
main; thisPR creates no useful land-order constraint with them. The currently active #172 touches the OAuth
WebSocket path instead and does not overlap this change.