fix(core): max_turns no longer clobbers a tripped input guardrail exception in streaming - #4606
Merged
seratch merged 2 commits intoAug 23, 2026
Conversation
…aming runs RunResultStreaming._check_errors() re-creates MaxTurnsExceeded and overwrites self._stored_exception on every call once current_turn > max_turns, because _max_turns_handled is only ever set True by the opt-in max_turns error-handler path in run_internal/run_loop.py. In the default (no custom handler) path it stays False forever, so _check_errors() re-fires on each of its three call sites in stream_events(), including the unconditional call in the finally block. Input guardrails default to run_in_parallel=True, so a tripped guardrail can already be drained into _stored_exception as InputGuardrailTripwireTriggered by an earlier _check_errors() call, then get silently overwritten by a freshly-minted MaxTurnsExceeded on a later one. The documented `except InputGuardrailTripwireTriggered` pattern never sees the tripwire. Set _max_turns_handled = True in the default branch too, mirroring what the handler path already does. Added a regression test in test_stream_input_guardrail_timing.py that trips an input guardrail on a run that also exceeds max_turns=1 and asserts the caller gets InputGuardrailTripwireTriggered, not MaxTurnsExceeded.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e91a42c513
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
test_max_turns_does_not_clobber_input_guardrail_tripwire ordered its race with asyncio.sleep(0.05) inside the tripping guardrail. Under CI load or slower model instrumentation, the guardrail could finish before current_turn > max_turns was ever reached, in which case the test would observe the correct exception even against the unpatched implementation and silently stop being a regression test. Replace the sleep with an asyncio.Event set by a max_turns error handler that declines (returns None, so it falls through to the exact same default raise path as no handler at all). The handler fires at the exact moment the run loop establishes current_turn > max_turns, and the guardrail now awaits that event before returning its tripwire, so it can only ever resolve after the max-turns condition genuinely holds. Verified 20/20 pass with the fix in place and 20/20 fail (on the correct assertion) with src/agents/result.py's fix reverted.
seratch
approved these changes
Aug 23, 2026
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
RunResultStreaming._check_errors()(src/agents/result.py, ~1047-1055) has this branch:_max_turns_handleddefaults toFalseand is only ever setTrueinside the opt-inerror_handlers["max_turns"]path inrun_internal/run_loop.py. If you don't register a custom max_turns handler (the default, and almost every caller), the flag never flips, so this branch is true on every single call to_check_errors()oncecurrent_turn > max_turns, and it unconditionally overwritesself._stored_exceptionwith a brand-newMaxTurnsExceededeach time._check_errors()is called three times insidestream_events()(~939, ~975, ~1000), and one of those calls is in thefinallyblock, so it always runs at least once more after the main loop has already broken out.Input guardrails default to
run_in_parallel=True(src/agents/guardrail.py:100). So the sequence that breaks is:max_turns._check_errors()runs, guardrail queue is still empty (guardrail hasn't finished), no guardrail exception yet, so nothing weird happens on that call._check_errors()call drains the guardrail queue and correctly setsself._stored_exception = InputGuardrailTripwireTriggered(...).stream_events()exits its main loop and hits thefinallyblock, which calls_check_errors()one more time. The guardrail queue is now empty (already drained in step 3), butcurrent_turn > max_turnsis still true and_max_turns_handledis stillFalse, so the branch fires again, builds a freshMaxTurnsExceeded, and stomps theInputGuardrailTripwireTriggeredthat was already stored.The caller ends up with
MaxTurnsExceededinstead ofInputGuardrailTripwireTriggered. The documentedexcept InputGuardrailTripwireTriggered:pattern silently never fires. This only happens in the default no-custom-handler path — if you registererror_handlers={"max_turns": ...}, the handler path sets_max_turns_handled = Trueand none of this happens, which is exactly why it went unnoticed.Fix
One line: set
self._max_turns_handled = Trueright after storing theMaxTurnsExceededin the default branch, mirroring what the handler path already does. Once it's set, subsequent_check_errors()calls skip the max_turns branch entirely and leave whatever's already inself._stored_exceptionalone.Test plan
Added
test_max_turns_does_not_clobber_input_guardrail_tripwiretotests/test_stream_input_guardrail_timing.py, using the existingScriptedModelfixture setup already used by the other tests in that file and intest_max_turns.py. It runs an agent withmax_turns=1, a tool that always gets called again (guaranteeing the turn limit is hit), and an input guardrail that trips after a shortasyncio.sleep(simulating a real moderation call that resolves slightly after the fast scripted model turns). It asserts the caller seesInputGuardrailTripwireTriggered, notMaxTurnsExceeded.Verified RED before GREEN:
git stashon justsrc/agents/result.py), the new test fails:Ran the targeted suites with the fix in place:
Also ran
ruff checkandruff format --checkon both changed files (clean), andmypydirectly onsrc/agents/result.py(clean, no issues). I did try the repo's fullmake typecheckvia.agents/skills/code-change-verification/scripts/run.sh, but it fails in my environment on unrelated optional-dependency modules (litellm, temporalio, sqlalchemy, the vercel/runloop sandbox extras) that aren't installed in a plainpip install -e .venv — none of those errors are in files this PR touches.Issue number
None filed; found by local repro while testing guardrail + max_turns interaction in a streamed run.