Fail Claude Code runs that end with a degenerate final message - #1692
Open
knechtionscoding wants to merge 1 commit into
Open
Fail Claude Code runs that end with a degenerate final message#1692knechtionscoding wants to merge 1 commit into
knechtionscoding wants to merge 1 commit into
Conversation
knechtionscoding
had a problem deploying
to
ok-to-test
August 25, 2026 17:23 — with
GitHub Actions
Error
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Claude Code can finish a run cleanly — subtype success, is_error false, stop_reason end_turn — after doing substantial work and still emit markup fragments instead of an answer as its final message. The existing completion gate in kelos-capture only inspects the termination fields, so such a run is recorded as Succeeded and the fragments are reported verbatim. Classify a result as incomplete when it would otherwise be completed, the run took at least 10 turns, and the trimmed final message is under 200 characters. Returning ResultIncomplete makes kelos-capture exit non-zero, which the Job's existing backoffLimit of 1 turns into a silent rerun — the Task stays Running across the retry, so a misjudged first attempt is invisible. The predicate measures the character length of the final message rather than usage.output_tokens, which is cumulative over the session and so stays large even when the final message is junk. Characters are counted as runes, so the floor means the same thing for a non-ASCII answer as for an ASCII one. kelos-capture also emits a degenerate output key so downstream consumers can tell this failure apart from any other. It is set only when degeneracy is what makes the run incomplete — an explicit error result is a different failure even when its short message trips the same length floor — and it is emitted on the failing attempt, alongside the response, which is captured either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
knechtionscoding
force-pushed
the
fix/detect-degenerate-claude-output
branch
from
August 25, 2026 17:40
04fd73b to
0100efa
Compare
knechtionscoding
requested a deployment
to
ok-to-test
August 25, 2026 17:41 — with
GitHub Actions
Waiting
Contributor
|
🤖 Kelos Task Status Task |
Contributor
|
🤖 Kelos Claude Reviewer Agent @gjkim42 Review SummaryVerdict: APPROVE Findings Overview
FindingsCorrectness / Documentation accuracy
Documentation completeness
Suggestions (optional)
Key takeaways
|
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.
What type of PR is this?
/kind bug
What this PR does / why we need it:
Claude Code can finish a run cleanly —
subtype: success,is_error: false,stop_reason: end_turn,terminal_reason: completed— after doing substantialreal work, and still emit markup fragments instead of an answer as its final
message. A Slack-facing agent did exactly this in production, ending a
41-turn run with:
The completion gate in
kelos-captureonly inspects the termination fields, sothis passes, the Task is recorded as Succeeded, and the fragments are reported
verbatim.
This adds a content predicate. A result is classified
ResultIncompletewhenall of the following hold:
ResultCompleted(an already-failing run is notsecond-guessed),
num_turns >= 10— the agent did substantial work, andResultIncompleteis reused rather than adding a new status, because it alreadymakes
kelos-captureexit non-zero. The Job's existing unconditionalbackoffLimit: 1turns that into a rerun, andisJobFailedwaits for theJobFailedcondition rather thanjob.Status.Failed > 0, so the Task staysRunningacross the retry. No new retry machinery is needed.kelos-capturealso emits adegenerate: trueoutput key on the failingattempt (
run()emits the marker block even when the exit code is 1), sodownstream consumers can distinguish this failure from any other without
duplicating the heuristic.
Which issue(s) this PR is related to:
N/A
Special notes for your reviewer:
The predicate measures characters, not tokens.
usage.output_tokenson theresult line appears to be cumulative over the session, not per-final-turn, so a
run with 40 tool calls carries a large
output_tokenseven when the finalmessage is junk — a token-based floor would silently never fire.
resultisunambiguously the final message, so measuring its length sidesteps the question.
Length is counted in runes, not bytes, so the floor means the same thing for a
non-ASCII answer as for an ASCII one.
The
degeneratemarker is gated on the classification, not the lengthfloor.
IsDegenerateOutput()is independent ofStatus(), so an expliciterror result (
subtype: error_max_turns) whose short error text trips the samefloor would otherwise be labelled degenerate. That is a different failure, and
mislabelling it would hide the real reason from downstream consumers, so the
marker is only set when the result is
ResultIncompleteand degenerate.This is not provider-side truncation. FireRouter was probed directly against
deepseek-v4-flash-0731:max_tokens: 8returnsstop_reason: max_tokensandmax_tokens: 512returnsend_turn.stop_reasonis forwarded faithfully, sothe existing
max_tokensguard already catches genuine truncation. The garbledrun came back as a real clean
end_turn; content is the only remaining signal.A false positive is cheap. If only the first attempt is misjudged, it costs
one retry and is invisible to the user — the Task stays
Runningand Slackshows only the progress message. If both attempts are misjudged, the Task goes
Failedand Slack posts the error header withresults["response"]stillattached, so the user still gets their answer. A false positive never loses an
answer, which is the argument for erring toward detection.
The thresholds (10 turns, 200 chars) are deliberately conservative and not
yet calibrated. There is no metric for final-message length today; the
distribution has to come from
task.Status.Results["response"]on completedTasks.
kelos_task_output_tokens_totalis labeled by spawner as ofc2faf5f1,so the token distribution per spawner is already queryable, but per the note
above tokens are the wrong measure here. Intent is to ship conservative and
tighten once the distribution is known.
Backward compatibility: older Claude Code versions that omit
num_turnsleaveit at zero, so the predicate never fires for them — matching how
Status()already tolerates empty reason fields. There is an explicit test for this.
Tests cover the real garbled sample after a high turn count, a legitimate brief
answer after two turns (
"Fixed in v0.472."— the case the predicate must notbreak, since the general spawner answers casually all the time), a long
coherent answer after many turns, the
charFloorandturnFloorboundaries,the missing-
num_turnscase, and thatdegenerate: truereaches the capturedoutputs on a failing attempt.
make testandmake verifypass.Does this PR introduce a user-facing change?