Repository navigation
Let a large-PR review read its diff and fail honestly past a limit - #2014
Merged
Merged
Conversation
A PR-review template binds {{nodes.fetch_diff.outputs.files}} into prose.
VariableResolver wrote an object or array into surrounding text with the
default HTML-safe encoder, so every CJK character and every + < > & ' of
the diff reached the model as a six-character \uXXXX: "修复" arrived as
修复 and every added line began +, at up to twice the bytes.
The model was reviewing escape codes. The string branch two lines above
already inserted all of those characters raw, so the escaping never
protected anything; it only made objects and strings disagree.
WorkflowJson.InterpolatedText leaves characters as themselves and still
escapes what JSON requires — the quote, the backslash, control characters
— so a value embedded in a JSON body stays parseable. MapResultsPrompt,
whose under-budget text must stay byte-identical to the resolver's, uses
the same options; its identity pin gains a CJK branch, since plain ASCII
could not tell two encoders apart. Characters outside the Basic
Multilingual Plane are still escaped: no built-in encoder lifts that.
When the model refused a request as larger than its context window, the run failed as an ordinary non-zero exit and the agent.run node respawned it. A respawn warm-resumes the conversation, so its request carries the goal again and overflows harder; a task-launched agent node, which defaults to three attempts, ended on three identical billed refusals. The text the author saw was the CLI's advice to its interactive user — trim your tools, start a new thread — which a workflow author cannot act on. AgentRetryCauses gains ContextWindowExceeded, matched against the phrases the CLIs themselves print, each observed from the real binary answered with the provider's own error body: Claude's "Prompt is too long", a gateway's "maximum context length is", OpenAI's context_length_exceeded and its message, Codex's rewording of a streaming failure, and Codex's own input_too_large. The tests run those real lines through ParseEvents and BuildResult before classifying, so the markers are pinned to what a failed run actually carries. The node treats the cause as deterministic unless a stronger model is on offer, and names it with the author's remedy. The cause carries no mitigation. The escalation trigger only escalates after a failed acceptance grade, which an overflowing run never reaches, and every other consumer keys on the format-fault cause specifically.
codex-rs refuses a turn/start input longer than 1,048,576 characters with input_too_large before any model request, and says so on stderr only — verified against 0.142.2, the worker's pin, and 0.147.0. The platform provisioned a sandbox and cloned the repository for a goal Codex would reject in the first second, and the stdin preflight allows up to 8 MiB encoded, well past that cap. The limit belongs to this CLI, so CodexHarness owns it (Rule 7): BuildInvocation refuses a longer goal with the same terminal SandboxArgumentTooLongException an argument the kernel cannot take gets, naming the goal's size and the cap and never the goal. Counted as Unicode scalar values, the way Rust counts a string's characters, so an emoji-heavy goal Codex accepts is not refused on its UTF-16 length. The cap is pinned by a test and moves by PR with the CLI; the context-window classifier also recognises the CLI's own refusal as the backstop.
The overflow cause was a substring scan over the run's error text. A fail-closed acceptance verdict overwrites that text with the rubric's own requirement and the judge's evidence, so a review whose rubric is about context windows classified as an overflow - and the escalation trigger, which stands down for any classified cause, stopped offering the stronger model the failed grade was evidence for. A crash whose last agent message quoted an overflow read the same way. Each harness folder now decides from what its CLI wrote: Claude's terminal_reason (or a 400 api_error relaying a gateway overflow body), Codex's terminal turn.failed (the provider's typed error code, or its own streamed-refusal sentence). The fold stamps a dedicated exit reason, and the classifier reads it off the exit reason only; text never classifies an overflow. A 5xx whose body mentions context length stays an ordinary, retryable failure. Codex refusing an input past its own character cap is not the model's window, so it folds to sandbox_argument_too_long - the code the harness preflight already throws - instead of advice to pick a model with a larger window, which could not help.
On a node with maxCostUsd, any attempt without a cumulative spend failed as "cannot be priced ... cumulative spend is missing", whatever killed it. A launch refused for its size never starts a process, so it never has a spend to read, and that sentence replaced the one telling the author to shorten the goal. Only a succeeded run still fails closed on its price, since its output would otherwise escape the cap unaccounted. A failed one keeps its own message and is never retried - which the retry's own prior-spend check would decide one attempt later anyway - and says so only when that is what stopped a failure a respawn could otherwise change.
The auth heuristic matched "401" as a substring, so a size refusal reporting a 1401234-character goal rendered as "Authentication failed" with a Fix credentials action, sending the author to rotate a working key. The digits now have to stand alone.
The doc claimed the HTML-safe escaping protected nothing. In a URL query, a header or a single-quoted sh -c string it did keep an object's & or ' from reading as syntax - protection a string value there never had, so no template could rely on it. State that instead.
The overflow predicate read api_error_status with TryGetInt32, which throws on a non-number. Claude writes "api_error_status": null on every connection-level failure - ConnectionRefused, ECONNRESET, a dead gateway, a broker listener that went away - so BuildResult threw on exactly those runs. The run landed as executor-error with a .NET message, and its diff, transcript, usage and session id were never captured; the real-model gates read that code as a code fault. Guard the kind first. Pinned with the pinned 2.1.263's own lines.
Claude refuses locally, sending nothing, once its own token estimate is past the window: terminal_reason "blocking_limit", result "Prompt is too long" (the pinned 2.1.263 does this from ~760 KB on a 200K model). It folded to non-zero-exit and was respawned with no cause named. Codex relays a vLLM or LiteLLM refusal verbatim, with the code as 400 or "400" and the reason only in the message, so it matched neither OpenAI's typed code nor Codex's own sentence - while the Claude fold typed the same gateway body. The gateway marker list now lives beside the exit reason and both folds read it, only off a relayed provider body. A 5xx stays retryable on both.
An unpriced failure is never retried under a cap, and the message said so - on every node, including one whose own policy allows a single attempt, where the cap decided nothing and sent the operator after pricing for a retry that could not have happened. The note now needs the node's retry policy too; the verdict is unchanged.
Neither "prompt is too long: N tokens > M maximum" nor "input length and max_tokens exceed context limit" matched a needle, so both were BadRequest. The supervisor brain's reactive compaction keys on ContextLengthExceeded and never ran for an Anthropic model whose window the operator had not declared, and every llm.complete overflow was labelled a malformed request.
A gateway's error text can be well-formed JSON that is not valid text: an unpaired surrogate escape (a preview cut in the middle of an emoji) parses, then throws on read. Codex relays such a 400 body verbatim, and the fold read its message to look for an overflow, so BuildResult threw on every such line, overflow or not. The run landed as executor-error and its session, diff and transcript were dropped - the same shape as the null-status throw, one field over. An unreadable body is now simply not a refusal the fold can type. The Claude fold's string reads get the same guard. Pinned with the pinned 0.142.2's own relayed lines.
The shared gateway markers matched a LiteLLM refusal only when the upstream was OpenAI-shaped. In front of a Claude or Bedrock model LiteLLM carries its own class name and the upstream's words, so a Codex agent on such a proxy was respawned into the same refusal. The list gains LiteLLM's ContextWindowExceededError and Anthropic's two refusals, still read only off a relayed 400 body. The Codex negative that stood for a 5xx was a line the pinned 0.142.2 never prints: it rewraps a 503, 413 or 422 as prose. The real lines are pinned now, and stay retryable whatever they mention.
blocking_limit shares the exit reason with a provider's refusal, but the CLI raises it before sending anything, measured against the window it believes. The node's cause suffix said the model had refused the request. It now says the request is larger than the window the CLI or the model allows, and the classifier's and exit reason's docs say the same.
Guarding the unreadable-message throw dropped the whole relayed body, including a code already read. A gateway refusal carrying OpenAI's typed context_length_exceeded beside a message with an unpaired surrogate escape was typed as an ordinary, retryable failure, and so was a vLLM overflow whose ASCII wording was intact. An unreadable string now stands in as its escaped text. Every word the folds look for is ASCII, which an escape cannot split, so one bad character neither throws out of the fold nor hides what the rest of the body states. A lookup that cannot unescape a gateway-authored key is still "not a refusal", never a throw.
A cold revise goal restates the whole contract, so a goal just under Codex's 1,048,576-character cap produces a revision past it. The harness refused that revision while the executor built it, and the throw left the revise loop: the generic catch replaced round 0's graded result - its diff, summary and verdict - with a bare size refusal, as if nothing had run. The refusal is about the round, so it now ends the revise loop the way a spend refusal does: a timeline note says why, and the last graded round's result stands. Nothing needs settling, because the round was never admitted.
ppXD
force-pushed
the
fix/let-a-large-pr-review-read-and-fail-honestly
branch
from
September 25, 2026 14:55
544d562 to
6d49c8c
Compare
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.
Summary
Makes a large-PR review read its diff intact, and makes it fail once, with a cause the author can act on, when the diff is past a limit. Each fix is its own commit.
VariableResolver.cs:137wrote an object into text with the default HTML-safe encoder, so every CJK character and every+ < > & 'became a six-character\uXXXX: 1.07x the bytes for an ASCII C# diff, up to ~2x for a CJK-heavy one.WorkflowJson.InterpolatedText(UnsafeRelaxedJsonEscaping) leaves characters as themselves and still escapes what JSON requires.MapResultsPromptuses the same options, because its under-budget text must stay byte-identical to the resolver's. An object now behaves like the string branch beside it, which always inserted these characters raw. In a URL query, a header or a single-quotedsh -cstring the old escape codes did keep an object's&or'from reading as syntax. A string there never had that protection, so no template could rely on it; the doc now says so.non-zero-exit, and a respawn warm-resumes, re-sending the goal inside a longer request. A task-launched node (three default attempts) ended on three identical refusals.AgentTerminalOutcomeReader.ContextWindowExceededExitReason(context-window-exceeded), andAgentRetryCauses.Classifyreads it off the exit reason only. Text never classifies: a text scan also matched a fail-closed rubric about context windows, which switched model escalation off for its failed grade.terminal_reasonprompt_too_long(the provider refused), orblocking_limit(the CLI's own estimate is past the window, so it sent nothing — the pinned 2.1.263 does this from ~760 KB on a 200K model), or a 400api_errorrelaying a gateway overflow body.turn.failedcarries OpenAI'scontext_length_exceeded, a gateway overflow message (vLLM's wording, LiteLLM'sContextWindowExceededError, or an Anthropic refusal behind a proxy — one shared marker list, so the two folds agree), or Codex's own streamed-refusal sentence.api_error_status: null).executor-errorand drops its session, diff and transcript.AgentCodeNodetreats the overflow as deterministic. Its message says the request is larger than the window the CLI or the model allows (the CLI raisesblocking_limitbefore sending anything), and names the author's remedy.CodexHarness.BuildInvocationnow refuses first, as the terminalSandboxArgumentTooLongException, counting Unicode scalar values the way Rust does. The cap is harness-owned (Rule 7) and pinned. When the CLI does refuse (input_too_largeon stderr), the fold reports the same code rather than advice to pick a larger-window model. A cold revise goal restates the whole contract, so it can cross the cap the original goal fit under. That refusal stops the revision, not the run: the last graded round's result stands, and the timeline says why, the same way a spend refusal stops a revision.401now has to stand alone. On the LLM plane,LlmApiException.Classifylabelled both Anthropic overflow bodiesBadRequest, so the supervisor brain's reactive compaction never ran for them; they areContextLengthExceedednow.Test plan
+ < > & 'interpolated into prose keeps them readable (red first). A value embedded in a JSON body still parses and round-trips. The resolver/MapResultsPromptbyte-identity pin gains a CJK branch; mutation-checked.ParseEvents+BuildResult. The lines come from real CLIs (Claude Code 2.1.226 and the pinned 2.1.263, Codex 0.147.0 and the pinned 0.142.2), with a dummy key against a local stub, unless noted:prompt_too_longshapes,blocking_limit, and a gateway's 400.context_length_exceeded, Codex's streamed rewording, and vLLM (code: 400) and LiteLLM (code: "400") bodies relayed by 0.142.2.ConnectionRefused,ECONNRESET;api_error_status: null) fold tonon-zero-exitwith the CLI's own text and session id.non-zero-exit.input_too_largefolds tosandbox_argument_too_long.acceptance-failed), changed files and branch stand, and the timeline says the revision stopped. It was red before: the throw replaced the whole result with a bare size refusal.401shapes still are.ContextLengthExceeded.Notes
CLAUDE_CODE_MAX_CONTEXT_TOKENSis set, soblocking_limitfires at 200K there. The harness does not pass the credential model's declared window yet.