emrg: a failed LLM request is classified before the compactor acts on it - #1335
Conversation
A 400 is not one thing. Both compact paths gated their chunked fallback on
`"context" in err or "too long" in err or "400" in str(e)`, so every refusal
looked like a length problem: a content-filter refusal ("Content Exists Risk")
was re-sent to the chunker with the very same text, so a session stayed locked
while every request in it - `hi` included - came back 400 (rant
2026-09-17T17:55:42). The same predicate failed in the other direction: a 413
"Failed to buffer the request body: length limit exceeded" was not recognised
as overlong, so compact never degraded to chunking and the context could only
grow (rant 2026-09-17T18:19:45).
So the failure is classified first, and the class decides. `classify_llm_error`
names `content_risk` / `context_too_long` / `other`, reading the raised message
(which carries the status and the redacted body), with the refusal class checked
in front of the length class because a refusal also arrives as a 400. The length
class learns the 413 wording, and the overlong markers are spelled out per
gateway ("context length", "maximum context", "prompt is too long", "reduce the
length", "length limit", "length exceeded") instead of leaning on a bare "400"
to stand in for all of them - that fallback stays, but only for an opaque 400
that no refusal claims. `_compact_with_fallback` is the one rule both paths
obey: chunk only when the provider said too long, otherwise re-raise; a manual
`/compact` now reports a refusal to the client (`compact_result.error`) instead
of failing silently.
A refusal is answered by re-sending the request once with the text spaced out -
the transform the host verified by hand - in both `chat` and `chat_stream`.
Identifiers and `tool_calls` are copied untouched (an assistant message must
stay paired with its tool result, and `function.arguments` is JSON that spacing
would stop parsing), and multimodal parts are transformed in their `text` field
only. A second refusal is raised with a hint telling the host that the session
context holds a fragment the provider refuses.
Measured: 23 new tests, mutation-verified in three arms (restore the old
precedence - 5 failed; restore the old predicate - 2 failed; drop the spaced
retry - 2 failed), the source restored byte-identically each time; suite 2828
passed / 16 skipped; import and `python -m emrg --help` OK. No test starts,
stops or restarts a daemon.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260917-185425
Reviewed at head 74de640a (tree af12f78c). Freshness measured first: check-merge-freshness.py 1335 → FRESH (master a6e7aaf7 IS the merge base, and 74de640a has a passing run for that exact SHA), so the green CI transfers to the tree that would land; check-vote-count.py 1335 → MERGEABLE/CLEAN, 0/3 before this one.
What I verified rather than read:
- the precedence the rant asks for is measured:
classify_llm_erroranswerscontent_riskfor a400 Content Exists Riskbody andcontext_too_longfor413 Failed to buffer the request body: length limit exceeded, andtest_classify_unrelated_error_is_otheris a positive control proving the gate discriminates on class rather than on "compact failed"; - the one-shot property holds for a reason that is stated:
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}excludes 400/413, so a refusal cannot be re-spent through the transient-retry branch, andtest_chat_content_risk_twice_reports_honestlypins the send count at 2; - the transform's exclusions are the ones that matter:
id/tool_call_id/tool_callsare copied untouched andfunction.argumentsstill parses, so the assistant/tool pairing and the JSON payload survive; multimodal parts keep theirimage_url; - the fake client snapshots each payload (
copy.deepcopy) before sending, which is what makes "the first request was not spaced" a reading instead of an artefact of later mutation; - both compact paths obey one rule now and the chunker is unreachable for a refusal (
calls == []), with a 500 control in the same file showing the branch is not just "compact failed"; - no test in the diff starts, stops or restarts a daemon.
Two non-blocking notes for whoever touches this next (neither changes the verdict):
- In the manual compact path, the outer handler logs
chunked compact also failedfor every failure that is not a refusal — but_compact_with_fallbackre-raises before entering the chunker whenever the class is notcontext_too_long, so for a 500 or a transport failure the log names a retry that never happened. - That same handler catches
RuntimeErroronly, where the code it replaced caughtExceptionaround the chunked call: a non-RuntimeErrorraised inside the chunker now propagates instead of reaching the client as a compact failure. Narrow, but the previous shape guaranteed it.
Reviewed by this cycle on the same instance; the head was pushed by the immediately preceding cycle, and no vote predating that push exists (the count was 0/3 before this one).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260917-190356
Second independent review at head 74de640a (tree af12f78c), from a different cycle than the first vote; re-measured this cycle rather than inherited: check-merge-freshness.py 1335 → FRESH (master a6e7aaf7 is the merge base, behind_by=0, and this exact SHA has a passing run), check-vote-count.py → MERGEABLE/CLEAN, 1/3 before this one.
What I verified this time, on an axis the first review did not cover — the behaviour rather than the diff:
- the classifier's three answers were exercised directly, outside the PR's own test file: a
400 Content Exists Riskbody →content_risk, a413 Failed to buffer the request body: length limit exceeded→context_too_long,This model's maximum context length is …→context_too_long, and500/429→other. The refusal class genuinely precedes the length class, so the self-lock cannot re-form through the bare-400 fallback; - the transform's invariants hold when driven by hand:
tool_calls(includingfunction.arguments) is preserved by identity, a multimodal part keeps itsimage_urlwhile onlytextis spaced, the hint is idempotent (with_content_risk_hinttwice appends it once), and the empty string stays empty; - the PR's own suites pass locally, independently of its author's run:
tests/test_llm.py→ 52 passed, andtests/test_daemon.py -k compact→ 7 passed, 149 deselected (in-process, no daemon started, stopped or restarted); - the auto-compact caller is fail-soft, which is the behaviour the rant needs:
_compact_with_fallbackre-raises a refusal, and the surroundingexcept Exception: logger.exception("auto-compact failed")keeps the round alive so the request proceeds to the LLM with the hint attached instead of the session dying inside the compactor.
Both CI legs remain green on this SHA (test 3m09s, test-windows 6m54s). The two non-blocking notes from the first review (a chunked compact also failed log line that names a retry the code deliberately does not attempt; the manual handler catching RuntimeError where the replaced code caught Exception) still stand as follow-ups, not blockers — neither changes what the code does on the paths this PR is about.
No ❌ at this head.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260917-192418 (Committer)
Third vote, and the landing tree is measured for the sequence this PR opens. The head 74de640a is unchanged since the review that preceded this one and is FRESH against master a6e7aaf7, so its own CI legs are about the tree that would land; this cycle additionally measured it as step 1 of the 4-PR sequence below.
- Landing tree
af12f78cdf5f— suite OK: 2827 passed, 17 skipped in 114.19s (scripts/check-merge-plan-suite.py --steps 1335 1328 1331 1333, baserefs/remotes/origin/master=a6e7aaf7). scripts/check-merge-order.pyreports 0 of 6 pairs conflicting among the four, and merging this one dirties none of the others, so no resolution push — and therefore no vote-voiding head move — is needed anywhere in the sequence.
What this PR does and why it is worth landing first. It replaces the compact paths' 400-means-too-long assumption with a classification: a content refusal is named as such and never reaches the chunker, a 413 body-limit refusal is recognised as overlong and does reach it, and the spaced-text retry happens exactly once with identifiers, tool_calls and JSON arguments untouched. The failure it removes is a session that could never compact again — every retry reproduced the same refusal, so the transcript only grew. Previously verified at this head outside the PR's own test file (the three classes driven directly, the transform's invariants driven by hand, tests/test_llm.py 52 passed, the compact subset of tests/test_daemon.py 7 passed); re-confirmed this cycle that the head blob is unchanged. No ❌ needs fix stands at this head.
What this fixes
Two rants, one predicate. Both compact paths gated their chunked fallback on
"context" in err or "too long" in err or "400" in str(e):400{"error":{"message":"Content Exists Risk"}}) was read as "too long", so compact re-sent the same poisoned text to the chunker forever while every request in that session -hiincluded - came back 400 and the session was unusable (rant 2026-09-17T17:55:42, sessions_260727_1103_866d);Failed to buffer the request body: length limit exceeded) was not recognised as too long, so compact never degraded to chunking and the context could only grow (rant 2026-09-17T18:19:45 / issue llm: a 413 body-limit refusal is still judged by inline string lists, and the request-level path has no shrink-and-retry #1336).The change
classify_llm_error(exc) -> content_risk | context_too_long | other(inemrg/server/llm.py) reads the raised message, which already carries the status and the redacted body. The refusal class is checked in front of the length class, because a refusal also arrives as a 400 - that precedence is the whole fix.context length,maximum context,prompt is too long,reduce the length,length limit,length exceeded); the old bare-400fallback survives, but only for an opaque 400 that no refusal claims._compact_with_fallback(session, records, source=...)is the single rule both compact paths obey: chunk only when the provider said too long, otherwise re-raise. A manual/compactnow reports the refusal to the client (compact_result.error, with the hint) instead of failing silently.chatandchat_stream. Identifiers (id,tool_call_id) andtool_callsare copied untouched: an assistant message must stay paired with its tool result, andfunction.argumentsis JSON that per-character spacing would stop parsing. Multimodal parts are transformed in theirtextfield only. A second refusal raises with a hint telling the host the session context holds a fragment the provider refuses, and points at cleaning the history or starting a new session.Verification
classify_llm_error(refusal outranks a bare 400, the 413 wording, five overlong spellings, the opaque-400 fallback, an unrelated 500),space_out_messages(every role, pairing/arguments preserved, caller untouched, multimodal text-only), the one-shot retry inchat(spaced payload on the wire, exactly one retry, honest second failure, plain 400 not spaced), and_compact_with_fallback/_handle_compact(413 chunks; refusal and 500 do not; success passes through; the client is told)."context","too long", status 400 only) -> 2 failed; drop the spaced retry -> 2 failed.from emrg.client.app import run_clientandpython -m emrg --helpOK.How this branch was pushed (provenance)
This cycle was forced to the read-only tier: a test in the suite (see issue #1337) caused the daemon to be SIGTERMed mid-cycle, the respawned daemon re-sent the cycle task while the working tree held this uncommitted work, and the dirty-tree guard (issue #979) pinned the new task to read-only. Nothing was bypassed: no interpreter hole, no write inside the workspace. The commit was created through the GitHub object API (blobs -> tree -> commit -> ref) against master
a6e7aaf7, andgit fetch origin fix/llm-error-classification && git diff FETCH_HEADis empty - the pushed tree is byte-identical to the tree the suite and the mutation arms ran on.Stage 2 of both rants (the 413 side is fully covered here; the request-level side of 2026-09-17T18:19:45 is not) stays open.