Skip to content

Discard a failed turn's tool calls and admit the last allowed turn - #102

Merged
udaychandra merged 11 commits into
mainfrom
agent/audit-turn-outcomes
Oct 21, 2026
Merged

udaychandra merged 11 commits into
mainfrom
agent/audit-turn-outcomes

Conversation

@udaychandra

@udaychandra udaychandra commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Spec audit-turn-outcomes (audit F08, F18).

Tool calls of a failed turn no longer run. TurnRunner runs a turn's collected tool calls only when the turn ended in TOOL_CALLS or STOP (endedCleanly, which replaces isRefused). ERROR (stream error, cancellation, a stream without its MessageStop), LENGTH, REFUSAL and CONTENT_FILTER discard them: no dispatch, no ToolResult/ToolOutcome, no tool events, no tool calls in the PostModelTurn response, nothing in the history. The turn keeps its own outcome, so StopClassifier builds the matching terminal. The assistant message is appended in one place.

maxTurns = N admits N turns. StopClassifier checks the turn limit only when the turn's own ending would continue the loop (TOOL_CALLS, or STOP with queued messages). Wall-clock, budget and cancellation keep their place before it. When a PreStopHook Inject reverses a Success, AgentLoop classifies the turn again as one with a message queued, so the limits still apply on the last allowed turn.

Main code: +18 net lines (56 added, 38 removed across 7 files, mostly javadoc: ResultMessage.ErrorMaxTurns, SessionLimits.maxTurns, PreStopHook now say what the last allowed turn does). SessionSecretRedactionTest's stream-error case now asserts the redacted error text at the PostModelTurn hook, the one place that text still goes.

Tests: TurnOutcomesTest covers acceptance cases 1–4 at the loop level (stream error, LENGTH, CONTENT_FILTER, cancellation while the stream is open, missing MessageStop, a STOP turn carrying a tool call, the turn-limit cases and a last-turn pre-stop Inject); two StopClassifierTest cases cover the last allowed turn. Pre-merge review: hand mutation of every claimed behaviour (all killed), 20k–30k repetitions of TurnOutcomesTest with no failure.

mvn -B clean verify javadoc:jar: 7857 tests run, 0 failures, 62 skipped (tests that need live provider keys); PMD, CPD and coverage gates pass.

🤖 Generated with Claude Code

udaychandra and others added 11 commits October 21, 2026 14:50
A turn now runs the tool calls it emitted only when it ended in
TOOL_CALLS or STOP. A turn that ended in ERROR (a stream error, a
cancellation or a stream without its MessageStop), LENGTH, REFUSAL or
CONTENT_FILTER discards them: they never run, emit no tool event and
never reach the history, and the turn keeps its own outcome, so
StopClassifier builds its error terminal instead of the loop going on.

StopClassifier checks the turn limit only for a turn that would continue
the loop, so maxTurns = N admits N model turns and a turn that ends the
session on its own keeps its terminal on the last one.
…turn

A PreStopHook Inject on the last allowed turn reversed its Success and started
turn N+1 unchecked. The loop now classifies such a turn again as one with a
message queued, so it ends in ErrorMaxTurns. Drop the stale turn-ceiling entry
from the StopClassifier priority list.
A turn whose tool calls survive ended in TOOL_CALLS or STOP, never ERROR,
so the tool-call branch and the text branch share one append rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
AgentLoop.iterate classifies an injected turn again, so its class javadoc
says so rather than PreStopDecision's. Trim the StopClassifier turn-ceiling
entry and the CHANGELOG paragraph of what the table rows already say.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
twoAllowedTurnsAdmitAToolTurnAndItsAnswer scripts the same tool turn and
text turn and asserts the same single run and Success.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
No test failed when TurnRunner ran tool calls only for TOOL_CALLS turns,
so a model that reports STOP with its calls (an unknown stop spelling maps
to STOP) would have lost every tool call unnoticed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The cancellation test ran the loop by hand and so skipped the shared
check that the run ended on the LoopEnded event carrying its terminal,
which the spec asks of every test that proves a tool did not run. It
now runs through the same helper on its virtual thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@udaychandra
udaychandra merged commit dae3d32 into main Oct 21, 2026
2 checks passed
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