Skip to content

fix(openai): stop long sessions on OpenAI models dying with "Prompt is too long" - #167

Merged
bman654 merged 3 commits into
mainfrom
fix/compact-turn-text-only-any-tools
Sep 4, 2026
Merged

fix(openai): stop long sessions on OpenAI models dying with "Prompt is too long"#167
bman654 merged 3 commits into
mainfrom
fix/compact-turn-text-only-any-tools

Conversation

@bman654

@bman654 bman654 commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 Bash tool calls and then hard-blocked. The
inference log shows three orphan turns — output 731 / 852 / 737 tokens, last stream part
tool-input-delta / tool-call, the first at inputTokens: 199822 with cacheRead: 0
interleaved with ordinary turns showing the normal reasoning-delta → text-delta progression. The
compaction 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):

  1. The compaction fork carries the session's full tool list. The fork helper takes a noTools
    flag and empties options.tools only when set; no compaction caller passes it.
  2. Only prompt wording stops the model — and an emitted call buys nothing. The fork's
    canUseTool denies every call outright ("Tool use is not allowed during compaction") and allows
    one turn, so a tool call burns the turn and returns no text. Blocking it at the provider is the
    only thing that helps.
  3. A tool-call-only response is discarded. The extractor wants an assistant message containing a
    text block; without one the attempt returns summarization produced empty response.
  4. Three consecutive failures open a circuit breaker. var B4n = 3; the auto-compact entry
    returns {kind: "failure_breaker_open"} before making any API call. The counter lives in the
    query 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.
  5. The old precondition was too narrow — not dead. The detector also required a tool named
    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.ts and src/server/router.ts are the only callers of translateRequest. Anthropic
passthrough 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):

count
Compaction requests on the translated (OpenAI) route 173
…carrying StructuredOutput (already protected) 158
without it (unprotected before this PR) 15 — 8 background, 7 interactive
Anthropic passthrough (this code path not involved) 21
Carrying a top-level diagnostics key 0

So 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, /compact up-to-a-selected-message, and
each 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 and
Claude 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 the StructuredOutput precondition; rename
    isClaudeCodeStructuredOutputCompactRequestisClaudeCodeCompactRequest; rewrite the doc
    comment, 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 diagnostics early 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.ts and tests/sdk-adapter.test.ts reaching the final user message
as text
, via an @-mention or Claude Code's post-compaction file restore. That would have made
the 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
startsWith on some text block costs nothing. A cross-family refuter and a fresh
counterexample-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 -t isolation).

  • The positive test is red on main. A compaction request from a shell-heavy session — tools
    Bash/Read/Grep, no StructuredOutput anywhere, envelope verbatim from the 2.1.259 bundle —
    asserts toolChoice === 'none'. On unmodified main: expected 'auto' to be 'none'.
  • Arrival shapes come from the census, not invention: [tool_result, text] (193 of 194 observed),
    [text, text] (1 of 194), a trailing reminder block, and plain-string content. Manual-/compact
    and "Additional Instructions" variants are covered.
  • The existing with-StructuredOutput coverage is unchanged, so the narrower case cannot regress.
  • Negatives: ordinary turns keep auto/required/named tool choice; the envelope quoted inline;
    quoted in a text block beside a tool_result; carried inside a tool_result (a Read or
    cat of 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)

Mutation Red
Delete the feature 2
Detector always true / always false 3 / 2
Head marker ignored / tail marker ignored 2 / 2
Head marker unanchored (the pre-review behaviour) 1
Head marker shortened / tail marker shortened 1 / 1
messages.at(0) instead of .at(-1) 2
Drop the diagnostics bail / drop the role === 'user' check 1 / 1
Also scan tool_result content 1
Only the first text block / only the last text block 1 / 1
Markers allowed in different blocks 1

Five 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 translateRequest in three arrival shapes: 15/15 none, and the same text quoted or inside
a tool_result stays auto.

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. It
examines 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 platform
bundle 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.ts from the new wording. Exact marker
presence catches removal or in-place rewording; it cannot detect a new third builder that leaves both
old strings in the bundle.

Additional discriminating evidence

  • The warning detector was exercised through both real translation routes: the default proxy HTTP
    handler and the endpoint server handler each write the sighting to their configured debug log.
    Deleting either production log: plog connection fails its full route test.
  • Broadening or deleting each of the following loose-recognizer constraints is detected by the full
    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.
  • A full subprocess integration test builds a synthetic Bun executable whose independent fixture is
    missing END, runs scripts/probe-patch-mechanism.mjs --json, and requires exit 1, a failed
    compact-prompt-markers check naming end, and the autonomous triage guidance.
  • Against a real Claude Code 2.1.257 Linux x64-musl executable, changing the sole END-marker byte made
    the full probe exit 1 with only compact-prompt-markers failed; the pristine executable passed.
    Disabling the marker verdict made that mutation pass, proving the check decides the result.
  • The canary self-test covers probe=error / execution=pass, preserves infrastructure reasons when
    an execution failure wins status precedence, excludes those lines from release evidence, and
    proves generated MODE=probe runs with isolated home/config/install paths. It also renders the
    real 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: a
shell-heavy history with the verbatim compaction envelope merged into the final tool_result turn,
tools Bash/Read/Grep, no StructuredOutput.

The two arms send byte-identical model input. The only difference is a top-level diagnostics
key, 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_choice is
{type: "any"} so the guard-off outcome is unambiguous.

Build Guard Responses containing a tool call
main (pre-fix) off 5/5 Bash, 0 chars of text
main (pre-fix) on 5/5 Bash, 0 chars of text — the guard cannot fire without StructuredOutput
this branch off 5/5 Bash, 0 chars of text
this branch on 0/5 — text-only summaries, 2069–2693 chars

And with the envelope merely quoted ("Explain this envelope to me:\n" + envelope), this branch
returns a Bash call 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 lunaMODEL-OK.

Latest gate: pnpm typecheck && pnpm test && pnpm build green — 105 files / 2093 tests on Node
v24.14.1, with CLAUDE_CODE_ENTRYPOINT=cli, a throwaway CLODEX_HOME, and both HTTP_PROXY and
HTTPS_PROXY set to http://127.0.0.1:9 so no ambient bridge could mask a failure.

What I could not verify

  • I could not reproduce the model ignoring the instruction on demand. With tool_choice: auto
    and 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 site
    passes no tool choice at all. It is a forcing function I chose to make the control unambiguous,
    and diagnostics on 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.
  • All A/B evidence was taken in --endpoint mode, while the fix's primary target is the default
    proxy 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.
  • n=5 per cell. Ample for a deterministic guard; far too few for any claim about a rate of model
    disobedience, which I am not making.
  • Only the OpenAI/ChatGPT-OAuth route was exercised live. I have no credentials for the other
    SDK-translated providers. The change is provider-independent, but that is reasoning, not
    measurement.
  • The original builder/control-flow facts are from 2.1.259. The loose recognizer's central
    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.
  • The changed canary driver has not completed a fresh real eight-platform production run. Its
    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 check
    has 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 main at 09d3cdb, then merged current origin/main at 068273c after the follow-up
commit. 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 new
timeout 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; this
PR creates no useful land-order constraint with them. The currently active #172 touches the OAuth
WebSocket path instead and does not overlap this change.

integ 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
bman654 merged commit a6fddd9 into main Sep 4, 2026
5 checks passed
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)
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